Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .chloggen/feat_issuegenerator-failed-job-link.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
change_type: enhancement
component: issuegenerator
note: "Link to all failing jobs in GitHub Actions instead of general workflow run."
issues: [1183]
subtext:
78 changes: 74 additions & 4 deletions issuegenerator/internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"

Expand Down Expand Up @@ -63,13 +64,27 @@ ${failedTests}

**Note**: Information about any subsequent build failures that happen while
this issue is open, will be added as comments with more information to this issue.

<details>
<summary>Failing job(s)</summary>

${failedJobs}

</details>
`
issueCommentTemplate = `
Link to latest failed build: ${linkToBuild}
Commit: ${commit}
PR: ${prNumber}

${failedTests}

<details>
<summary>Failing job(s)</summary>

${failedJobs}

</details>
`
prCommentTemplate = `@${prAuthor} some tests are failing on main after these changes.
Details: ${issueLink}
Expand Down Expand Up @@ -212,7 +227,7 @@ func (c *Client) CommentOnIssue(ctx context.Context, r report.Report, issue *git
commitMessage := c.getCommitMessage(ctx)
prNumber := c.extractPRNumberFromCommitMessage(commitMessage)

body := os.Expand(issueCommentTemplate, templateHelper(c.envVariables, r, prNumber))
body := os.Expand(issueCommentTemplate, templateHelper(ctx, c, c.envVariables, r, prNumber))

issueComment, response, err := c.client.Issues.CreateComment(
ctx,
Expand Down Expand Up @@ -261,13 +276,36 @@ func getComponent(module string) string {
return module
}

func templateHelper(env map[string]string, r report.Report, prNumber int) func(string) string {
func templateHelper(ctx context.Context, c *Client, env map[string]string, r report.Report, prNumber int) func(string) string {
return func(param string) string {
switch param {
case "jobName":
return "`" + env[githubWorkflow] + "`"
case "linkToBuild":
return fmt.Sprintf("%s/%s/%s/actions/runs/%s", env[githubServerURL], env[githubOwner], env[githubRepository], env[githubRunID])
return c.getRunURL(env[githubRunID])
case "failedJobs":
runID, err := strconv.ParseInt(env[githubRunID], 10, 64)
if err != nil {
return ""
}
failedJobs, err := c.getFailedJobURLs(ctx, runID)
if err != nil {
c.logger.Warn("Failed to get failed job URLs", zap.Error(err))
return ""
}
if len(failedJobs) == 0 {
return ""
}
names := make([]string, 0, len(failedJobs))
for name := range failedJobs {
names = append(names, name)
}
sort.Strings(names)
var sb strings.Builder
for _, name := range names {
fmt.Fprintf(&sb, "- [`%s`](%s)\n", name, failedJobs[name])
}
return sb.String()
case "failedTests":
return r.FailedTestsMD()
case "component":
Expand All @@ -286,6 +324,38 @@ func templateHelper(env map[string]string, r report.Report, prNumber int) func(s
}
}

// getFailedJobURLs fetches the names and URLs of the failed jobs in the current run.
func (c *Client) getFailedJobURLs(ctx context.Context, runID int64) (map[string]string, error) {
failedJobs := make(map[string]string)
opts := &github.ListWorkflowJobsOptions{ListOptions: github.ListOptions{PerPage: 100}}
for {
jobs, resp, err := c.client.Actions.ListWorkflowJobs(ctx, c.envVariables[githubOwner], c.envVariables[githubRepository], runID, opts)
if err != nil {
return nil, err
}
for _, job := range jobs.Jobs {
if job.GetConclusion() == "failure" {
failedJobs[job.GetName()] = job.GetHTMLURL()
}
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return failedJobs, nil
}

// getRunURL returns the generic workflow run URL.
func (c *Client) getRunURL(runID string) string {
return fmt.Sprintf("%s/%s/%s/actions/runs/%s",
c.envVariables[githubServerURL],
c.envVariables[githubOwner],
c.envVariables[githubRepository],
runID,
)
}

// shortSha returns the first 7 characters of a full Git commit SHA
func shortSha(sha string) string {
if len(sha) >= 7 {
Expand Down Expand Up @@ -442,7 +512,7 @@ func (c *Client) CreateIssue(ctx context.Context, r report.Report) *github.Issue
commitMessage := c.getCommitMessage(ctx)
prNumber := c.extractPRNumberFromCommitMessage(commitMessage)

body := os.Expand(issueBodyTemplate, templateHelper(c.envVariables, r, prNumber))
body := os.Expand(issueBodyTemplate, templateHelper(ctx, c, c.envVariables, r, prNumber))
componentName := getComponent(trimmedModule)

issueLabels := c.cfg.labelsCopy()
Expand Down
134 changes: 133 additions & 1 deletion issuegenerator/internal/github/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ PR: N/A

**Note**: Information about any subsequent build failures that happen while
this issue is open, will be added as comments with more information to this issue.

<details>
<summary>Failing job(s)</summary>



</details>
`,
},
{
Expand All @@ -152,14 +159,26 @@ PR: N/A

` + "```" + `


<details>
<summary>Failing job(s)</summary>



</details>
`,
},
}

require.GreaterOrEqual(t, len(reports), len(tests))
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := os.Expand(tt.template, templateHelper(envVariables, reports[i], 0))
// Return empty list to test the fallback logic (getRunURL)
mockedHTTPClient := newMockHTTPClient(t, mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, &github.Jobs{Jobs: []*github.WorkflowJob{}}, 0)
client := newTestClient(t, mockedHTTPClient)
client.envVariables = envVariables

result := os.Expand(tt.template, templateHelper(t.Context(), client, envVariables, reports[i], 0))
assert.Equal(t, tt.expected, result)
})
}
Expand Down Expand Up @@ -392,3 +411,116 @@ func TestExtractPRNumberFromMessage(t *testing.T) {
})
}
}

func TestGetFailedJobURLs(t *testing.T) {
tests := []struct {
name string
jobs []*github.WorkflowJob
expected map[string]string
}{
{
name: "single failure found",
jobs: []*github.WorkflowJob{
{Name: github.Ptr("Success Job"), Conclusion: github.Ptr("success"), HTMLURL: github.Ptr("http://job/1")},
{Name: github.Ptr("Failed Job"), Conclusion: github.Ptr("failure"), HTMLURL: github.Ptr("http://job/2")},
},
expected: map[string]string{"Failed Job": "http://job/2"},
},
{
name: "multiple failures",
jobs: []*github.WorkflowJob{
{Name: github.Ptr("Lint"), Conclusion: github.Ptr("failure"), HTMLURL: github.Ptr("http://job/1")},
{Name: github.Ptr("Test-Linux"), Conclusion: github.Ptr("failure"), HTMLURL: github.Ptr("http://job/2")},
{Name: github.Ptr("Test-Windows"), Conclusion: github.Ptr("success"), HTMLURL: github.Ptr("http://job/3")},
},
expected: map[string]string{"Lint": "http://job/1", "Test-Linux": "http://job/2"},
},
{
name: "no failures found",
jobs: []*github.WorkflowJob{
{Name: github.Ptr("Success Job"), Conclusion: github.Ptr("success"), HTMLURL: github.Ptr("http://job/1")},
},
expected: map[string]string{},
},
{
name: "timed_out jobs are excluded",
jobs: []*github.WorkflowJob{
{Name: github.Ptr("Failed Job"), Conclusion: github.Ptr("failure"), HTMLURL: github.Ptr("http://job/1")},
{Name: github.Ptr("Timed out Job"), Conclusion: github.Ptr("timed_out"), HTMLURL: github.Ptr("http://job/2")},
},
expected: map[string]string{"Failed Job": "http://job/1"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockResponse := &github.Jobs{Jobs: tt.jobs}
mockedHTTPClient := newMockHTTPClient(t, mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, mockResponse, 0)
client := newTestClient(t, mockedHTTPClient)

failedJobs, err := client.getFailedJobURLs(t.Context(), 123)
assert.NoError(t, err)
assert.Equal(t, tt.expected, failedJobs)
})
}
}

func TestFailedJobsTemplateExpansion(t *testing.T) {
envVariables := map[string]string{
githubWorkflow: "test-ci",
githubServerURL: "https://github.com",
githubOwner: "test-org",
githubRepository: "test-repo",
githubRunID: "555555",
githubSHAKey: "abcde12345",
}

report := report.Report{
Module: "test-module",
}

t.Run("single failed job", func(t *testing.T) {
mockResponse := &github.Jobs{Jobs: []*github.WorkflowJob{
{Name: github.Ptr("Job A"), Conclusion: github.Ptr("failure"), HTMLURL: github.Ptr("http://job/a")},
}}
mockedHTTPClient := newMockHTTPClient(t, mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, mockResponse, 0)
client := newTestClient(t, mockedHTTPClient)
client.envVariables = envVariables

expand := templateHelper(t.Context(), client, envVariables, report, 0)
result := expand("failedJobs")

expected := "- [`Job A`](http://job/a)\n"
assert.Equal(t, expected, result)
})

t.Run("multiple failed jobs", func(t *testing.T) {
mockResponse := &github.Jobs{Jobs: []*github.WorkflowJob{
{Name: github.Ptr("Job A"), Conclusion: github.Ptr("failure"), HTMLURL: github.Ptr("http://job/a")},
{Name: github.Ptr("Job B"), Conclusion: github.Ptr("failure"), HTMLURL: github.Ptr("http://job/b")},
}}
mockedHTTPClient := newMockHTTPClient(t, mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, mockResponse, 0)
client := newTestClient(t, mockedHTTPClient)
client.envVariables = envVariables

expand := templateHelper(t.Context(), client, envVariables, report, 0)
result := expand("failedJobs")

expected := "- [`Job A`](http://job/a)\n- [`Job B`](http://job/b)\n"
assert.Equal(t, expected, result)
})

t.Run("no failed jobs", func(t *testing.T) {
mockResponse := &github.Jobs{Jobs: []*github.WorkflowJob{
{Name: github.Ptr("Job A"), Conclusion: github.Ptr("success"), HTMLURL: github.Ptr("http://job/a")},
}}
mockedHTTPClient := newMockHTTPClient(t, mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, mockResponse, 0)
client := newTestClient(t, mockedHTTPClient)
client.envVariables = envVariables

expand := templateHelper(t.Context(), client, envVariables, report, 0)
result := expand("failedJobs")

assert.Equal(t, "", result)
})
}
Loading