Skip to content

Commit b1b217d

Browse files
committed
feat(issuegenerator): link to specific failing jobs instead of generic workflow run
This change modifies the issue generator to report individual failed jobs within a workflow run. This provides direct links to the relevant logs for faster debugging. - Filters for strictly 'failure' conclusions, ignoring noise like 'timed_out'. - Supports deterministic bulleted lists when multiple jobs fail. - Adds explicit prefix text to reports for improved clarity. - Includes unit tests for filtering logic and template rendering.
1 parent 4c93dd4 commit b1b217d

File tree

3 files changed

+213
-15
lines changed

3 files changed

+213
-15
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
change_type: enhancement
2+
component: issuegenerator
3+
note: "Link to all failing jobs in GitHub Actions instead of general workflow run."
4+
issues: [1183]
5+
subtext:

issuegenerator/internal/github/client.go

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"net/http"
2525
"os"
2626
"regexp"
27+
"sort"
2728
"strconv"
2829
"strings"
2930

@@ -52,23 +53,25 @@ const (
5253
issueBodyTemplate = `
5354
Auto-generated report for ${jobName} job build.
5455
55-
Link to failed build: ${linkToBuild}
56+
Failing job(s): ${linkToBuild}
5657
Commit: ${commit}
5758
PR: ${prNumber}
5859
5960
### Component(s)
6061
${component}
6162
63+
The following tests failed:
6264
${failedTests}
6365
6466
**Note**: Information about any subsequent build failures that happen while
6567
this issue is open, will be added as comments with more information to this issue.
6668
`
6769
issueCommentTemplate = `
68-
Link to latest failed build: ${linkToBuild}
70+
Failing job(s): ${linkToBuild}
6971
Commit: ${commit}
7072
PR: ${prNumber}
7173
74+
The following tests failed:
7275
${failedTests}
7376
`
7477
prCommentTemplate = `@${prAuthor} some tests are failing on main after these changes.
@@ -208,11 +211,10 @@ func (c *Client) GetExistingIssue(ctx context.Context, module string) *github.Is
208211
// information about the latest failure. This method is expected to be
209212
// called only if there's an existing open Issue for the current job.
210213
func (c *Client) CommentOnIssue(ctx context.Context, r report.Report, issue *github.Issue) *github.IssueComment {
211-
// Get commit message and extract PR number
212214
commitMessage := c.getCommitMessage(ctx)
213215
prNumber := c.extractPRNumberFromCommitMessage(commitMessage)
214216

215-
body := os.Expand(issueCommentTemplate, templateHelper(c.envVariables, r, prNumber))
217+
body := os.Expand(issueCommentTemplate, templateHelper(ctx, c, c.envVariables, r, prNumber))
216218

217219
issueComment, response, err := c.client.Issues.CreateComment(
218220
ctx,
@@ -231,7 +233,6 @@ func (c *Client) CommentOnIssue(ctx context.Context, r report.Report, issue *git
231233
c.handleBadResponses(response)
232234
}
233235

234-
// Also comment on the PR with a link to this comment
235236
if prNumber > 0 && issueComment != nil && issueComment.HTMLURL != nil {
236237
if prAuthor := c.GetPRAuthor(ctx, prNumber); prAuthor != "" {
237238
_ = c.CommentOnPR(ctx, prNumber, prAuthor, *issueComment.HTMLURL)
@@ -261,13 +262,46 @@ func getComponent(module string) string {
261262
return module
262263
}
263264

264-
func templateHelper(env map[string]string, r report.Report, prNumber int) func(string) string {
265+
func templateHelper(ctx context.Context, c *Client, env map[string]string, r report.Report, prNumber int) func(string) string {
265266
return func(param string) string {
266267
switch param {
267268
case "jobName":
268269
return "`" + env[githubWorkflow] + "`"
269270
case "linkToBuild":
270-
return fmt.Sprintf("%s/%s/%s/actions/runs/%s", env[githubServerURL], env[githubOwner], env[githubRepository], env[githubRunID])
271+
runID, err := strconv.ParseInt(env[githubRunID], 10, 64)
272+
if err != nil {
273+
c.logger.Warn("Failed to parse run ID", zap.Error(err))
274+
return c.getRunURL(env[githubRunID])
275+
}
276+
277+
failedJobs, err := c.getFailedJobURLs(ctx, runID)
278+
if err != nil {
279+
c.logger.Warn("Failed to get failed job URLs", zap.Error(err))
280+
return c.getRunURL(strconv.FormatInt(runID, 10))
281+
}
282+
283+
if len(failedJobs) == 0 {
284+
return c.getRunURL(strconv.FormatInt(runID, 10))
285+
}
286+
287+
if len(failedJobs) == 1 {
288+
for _, url := range failedJobs {
289+
return url
290+
}
291+
}
292+
293+
// Sort by name for deterministic output
294+
names := make([]string, 0, len(failedJobs))
295+
for name := range failedJobs {
296+
names = append(names, name)
297+
}
298+
sort.Strings(names)
299+
300+
var sb strings.Builder
301+
for _, name := range names {
302+
fmt.Fprintf(&sb, "\n- [`%s`](%s)", name, failedJobs[name])
303+
}
304+
return sb.String()
271305
case "failedTests":
272306
return r.FailedTestsMD()
273307
case "component":
@@ -286,6 +320,38 @@ func templateHelper(env map[string]string, r report.Report, prNumber int) func(s
286320
}
287321
}
288322

323+
// getFailedJobURLs fetches the names and URLs of the failed jobs in the current run.
324+
func (c *Client) getFailedJobURLs(ctx context.Context, runID int64) (map[string]string, error) {
325+
failedJobs := make(map[string]string)
326+
opts := &github.ListWorkflowJobsOptions{ListOptions: github.ListOptions{PerPage: 100}}
327+
for {
328+
jobs, resp, err := c.client.Actions.ListWorkflowJobs(ctx, c.envVariables[githubOwner], c.envVariables[githubRepository], runID, opts)
329+
if err != nil {
330+
return nil, err
331+
}
332+
for _, job := range jobs.Jobs {
333+
if job.GetConclusion() == "failure" {
334+
failedJobs[job.GetName()] = job.GetHTMLURL()
335+
}
336+
}
337+
if resp.NextPage == 0 {
338+
break
339+
}
340+
opts.Page = resp.NextPage
341+
}
342+
return failedJobs, nil
343+
}
344+
345+
// getRunURL returns the generic workflow run URL.
346+
func (c *Client) getRunURL(runID string) string {
347+
return fmt.Sprintf("%s/%s/%s/actions/runs/%s",
348+
c.envVariables[githubServerURL],
349+
c.envVariables[githubOwner],
350+
c.envVariables[githubRepository],
351+
runID,
352+
)
353+
}
354+
289355
// shortSha returns the first 7 characters of a full Git commit SHA
290356
func shortSha(sha string) string {
291357
if len(sha) >= 7 {
@@ -404,10 +470,9 @@ func (c *Client) CommentOnPR(ctx context.Context, prNumber int, prAuthor string,
404470
}
405471

406472
func (c *Client) extractPRNumberFromCommitMessage(commitMsg string) int {
407-
// Only consider the first line of the commit message.
408473
firstLine := strings.SplitN(commitMsg, "\n", 2)[0]
409474

410-
// cases matched :
475+
// Cases matched:
411476
// - (#123)
412477
// - Merge pull request #123
413478
// - (#123): some description
@@ -438,11 +503,10 @@ func (c *Client) CreateIssue(ctx context.Context, r report.Report) *github.Issue
438503
trimmedModule := trimModule(c.envVariables[githubOwner], c.envVariables[githubRepository], r.Module)
439504
title := strings.Replace(issueTitleTemplate, "${module}", trimmedModule, 1)
440505

441-
// Get commit message and extract PR number
442506
commitMessage := c.getCommitMessage(ctx)
443507
prNumber := c.extractPRNumberFromCommitMessage(commitMessage)
444508

445-
body := os.Expand(issueBodyTemplate, templateHelper(c.envVariables, r, prNumber))
509+
body := os.Expand(issueBodyTemplate, templateHelper(ctx, c, c.envVariables, r, prNumber))
446510
componentName := getComponent(trimmedModule)
447511

448512
issueLabels := c.cfg.labelsCopy()
@@ -465,7 +529,6 @@ func (c *Client) CreateIssue(ctx context.Context, r report.Report) *github.Issue
465529
c.handleBadResponses(response)
466530
}
467531

468-
// After creating the issue, also comment on the PR with a link to the created issue
469532
if prNumber > 0 && issue != nil && issue.HTMLURL != nil {
470533
if prAuthor := c.GetPRAuthor(ctx, prNumber); prAuthor != "" {
471534
_ = c.CommentOnPR(ctx, prNumber, prAuthor, *issue.HTMLURL)

issuegenerator/internal/github/client_test.go

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
package github
1616

1717
import (
18+
"context"
1819
"encoding/json"
1920
"net/http"
21+
"net/http/httptest"
2022
"os"
2123
"path/filepath"
2224
"sort"
@@ -116,13 +118,14 @@ func TestTemplateExpansion(t *testing.T) {
116118
expected: `
117119
Auto-generated report for ` + "`test-ci`" + ` job build.
118120
119-
Link to failed build: https://github.com/test-org/test-repo/actions/runs/555555
121+
Failing job(s): https://github.com/test-org/test-repo/actions/runs/555555
120122
Commit: abcde12
121123
PR: N/A
122124
123125
### Component(s)
124126
` + "package1" + `
125127
128+
The following tests failed:
126129
#### Test Failures
127130
- ` + "`TestFailure`" + `
128131
` + "```" + `
@@ -140,10 +143,11 @@ this issue is open, will be added as comments with more information to this issu
140143
name: "issue comment template",
141144
template: issueCommentTemplate,
142145
expected: `
143-
Link to latest failed build: https://github.com/test-org/test-repo/actions/runs/555555
146+
Failing job(s): https://github.com/test-org/test-repo/actions/runs/555555
144147
Commit: abcde12
145148
PR: N/A
146149
150+
The following tests failed:
147151
#### Test Failures
148152
- ` + "`TestFailure`" + `
149153
` + "```" + `
@@ -159,7 +163,13 @@ PR: N/A
159163
require.GreaterOrEqual(t, len(reports), len(tests))
160164
for i, tt := range tests {
161165
t.Run(tt.name, func(t *testing.T) {
162-
result := os.Expand(tt.template, templateHelper(envVariables, reports[i], 0))
166+
// Return empty list to test the fallback logic (getRunURL)
167+
mockedHTTPClient := newMockHTTPClient(t, mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, &github.Jobs{Jobs: []*github.WorkflowJob{}}, 0)
168+
client := newTestClient(t, mockedHTTPClient)
169+
// Ensure client env vars match test vars
170+
client.envVariables = envVariables
171+
172+
result := os.Expand(tt.template, templateHelper(context.Background(), client, envVariables, reports[i], 0))
163173
assert.Equal(t, tt.expected, result)
164174
})
165175
}
@@ -392,3 +402,123 @@ func TestExtractPRNumberFromMessage(t *testing.T) {
392402
})
393403
}
394404
}
405+
406+
func TestGetFailedJobURLs(t *testing.T) {
407+
tests := []struct {
408+
name string
409+
runID int64
410+
mockResponse string
411+
expected map[string]string
412+
expectErr bool
413+
}{
414+
{
415+
name: "single failure found",
416+
runID: 123,
417+
mockResponse: `{"jobs": [
418+
{"id": 1, "name": "Success Job", "conclusion": "success", "html_url": "http://job/1"},
419+
{"id": 2, "name": "Failed Job", "conclusion": "failure", "html_url": "http://job/2"}
420+
]}`,
421+
expected: map[string]string{"Failed Job": "http://job/2"},
422+
},
423+
{
424+
name: "multiple failures",
425+
runID: 123,
426+
mockResponse: `{"jobs": [
427+
{"id": 1, "name": "Lint", "conclusion": "failure", "html_url": "http://job/1"},
428+
{"id": 2, "name": "Test-Linux", "conclusion": "failure", "html_url": "http://job/2"},
429+
{"id": 3, "name": "Test-Windows", "conclusion": "success", "html_url": "http://job/3"}
430+
]}`,
431+
expected: map[string]string{"Lint": "http://job/1", "Test-Linux": "http://job/2"},
432+
},
433+
{
434+
name: "no failures found",
435+
runID: 123,
436+
mockResponse: `{"jobs": [
437+
{"id": 1, "name": "Success Job", "conclusion": "success", "html_url": "http://job/1"}
438+
]}`,
439+
expected: map[string]string{},
440+
},
441+
{
442+
name: "timed_out jobs are excluded",
443+
runID: 123,
444+
mockResponse: `{"jobs": [
445+
{"id": 1, "name": "Failed Job", "conclusion": "failure", "html_url": "http://job/1"},
446+
{"id": 2, "name": "Timed out Job", "conclusion": "timed_out", "html_url": "http://job/2"}
447+
]}`,
448+
expected: map[string]string{"Failed Job": "http://job/1"},
449+
},
450+
}
451+
452+
for _, tt := range tests {
453+
t.Run(tt.name, func(t *testing.T) {
454+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
455+
w.WriteHeader(http.StatusOK)
456+
_, err := w.Write([]byte(tt.mockResponse))
457+
require.NoError(t, err)
458+
}))
459+
defer server.Close()
460+
461+
client := &Client{
462+
logger: zaptest.NewLogger(t),
463+
envVariables: map[string]string{
464+
githubOwner: testOwner,
465+
githubRepository: testRepo,
466+
githubServerURL: "https://github.com",
467+
},
468+
}
469+
470+
c, _ := github.NewClient(nil).WithEnterpriseURLs(server.URL, server.URL)
471+
client.client = c
472+
473+
failedJobs, err := client.getFailedJobURLs(context.Background(), tt.runID)
474+
if tt.expectErr {
475+
assert.Error(t, err)
476+
} else {
477+
assert.NoError(t, err)
478+
assert.Equal(t, tt.expected, failedJobs)
479+
}
480+
})
481+
}
482+
}
483+
484+
func TestMultiJobTemplateExpansion(t *testing.T) {
485+
envVariables := map[string]string{
486+
githubWorkflow: "test-ci",
487+
githubServerURL: "https://github.com",
488+
githubOwner: "test-org",
489+
githubRepository: "test-repo",
490+
githubRunID: "555555",
491+
githubSHAKey: "abcde12345",
492+
}
493+
494+
report := report.Report{
495+
Module: "test-module",
496+
}
497+
498+
t.Run("single failed job link", func(t *testing.T) {
499+
mockResponse := &github.Jobs{Jobs: []*github.WorkflowJob{
500+
{Name: github.Ptr("Job A"), Conclusion: github.Ptr("failure"), HTMLURL: github.Ptr("http://job/a")},
501+
}}
502+
mockedHTTPClient := newMockHTTPClient(t, mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, mockResponse, 0)
503+
client := newTestClient(t, mockedHTTPClient)
504+
client.envVariables = envVariables
505+
506+
expand := templateHelper(context.Background(), client, envVariables, report, 0)
507+
result := expand("linkToBuild")
508+
509+
assert.Equal(t, "http://job/a", result)
510+
})
511+
512+
t.Run("multiple failed jobs list", func(t *testing.T) {
513+
mockResponse := &github.Jobs{Jobs: []*github.WorkflowJob{
514+
{Name: github.Ptr("Job A"), Conclusion: github.Ptr("failure"), HTMLURL: github.Ptr("http://job/a")},
515+
{Name: github.Ptr("Job B"), Conclusion: github.Ptr("failure"), HTMLURL: github.Ptr("http://job/b")},
516+
}}
517+
mockedHTTPClient := newMockHTTPClient(t, mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, mockResponse, 0)
518+
client := newTestClient(t, mockedHTTPClient)
519+
client.envVariables = envVariables
520+
521+
expand := templateHelper(context.Background(), client, envVariables, report, 0)
522+
assert.Equal(t, "\n- [`Job A`](http://job/a)\n- [`Job B`](http://job/b)", expand("linkToBuild"))
523+
})
524+
}

0 commit comments

Comments
 (0)