Skip to content

Commit 09d969e

Browse files
committed
feat(issuegenerator): link to specific failing job instead of workflow run
1 parent c59ae7a commit 09d969e

File tree

3 files changed

+174
-5
lines changed

3 files changed

+174
-5
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
2+
change_type: enhancement
3+
4+
# The name of the component, or a single word describing the area of concern, (e.g. crosslink)
5+
component: issuegenerator
6+
7+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
8+
note: "Link to specific failing job in GitHub Issues instead of general workflow run."
9+
10+
# One or more tracking issues related to the change
11+
issues: [1183]
12+
13+
# (Optional) One or more lines of additional information to render under the primary note.
14+
# These lines will be padded with 2 spaces and then inserted directly into the document.
15+
# Use pipe (|) for multiline entries.
16+
subtext:

issuegenerator/internal/github/client.go

Lines changed: 70 additions & 4 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

@@ -212,7 +213,7 @@ func (c *Client) CommentOnIssue(ctx context.Context, r report.Report, issue *git
212213
commitMessage := c.getCommitMessage(ctx)
213214
prNumber := c.extractPRNumberFromCommitMessage(commitMessage)
214215

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

217218
issueComment, response, err := c.client.Issues.CreateComment(
218219
ctx,
@@ -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+
sb.WriteString(fmt.Sprintf("\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" || job.GetConclusion() == "timed_out" {
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 {
@@ -442,7 +508,7 @@ func (c *Client) CreateIssue(ctx context.Context, r report.Report) *github.Issue
442508
commitMessage := c.getCommitMessage(ctx)
443509
prNumber := c.extractPRNumberFromCommitMessage(commitMessage)
444510

445-
body := os.Expand(issueBodyTemplate, templateHelper(c.envVariables, r, prNumber))
511+
body := os.Expand(issueBodyTemplate, templateHelper(ctx, c, c.envVariables, r, prNumber))
446512
componentName := getComponent(trimmedModule)
447513

448514
issueLabels := c.cfg.labelsCopy()

issuegenerator/internal/github/client_test.go

Lines changed: 88 additions & 1 deletion
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"
@@ -159,7 +161,13 @@ PR: N/A
159161
require.GreaterOrEqual(t, len(reports), len(tests))
160162
for i, tt := range tests {
161163
t.Run(tt.name, func(t *testing.T) {
162-
result := os.Expand(tt.template, templateHelper(envVariables, reports[i], 0))
164+
// Return empty list to test the fallback logic (getRunURL)
165+
mockedHTTPClient := newMockHTTPClient(t, mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, &github.Jobs{Jobs: []*github.WorkflowJob{}}, 0)
166+
client := newTestClient(t, mockedHTTPClient)
167+
// Ensure client env vars match test vars
168+
client.envVariables = envVariables
169+
170+
result := os.Expand(tt.template, templateHelper(context.Background(), client, envVariables, reports[i], 0))
163171
assert.Equal(t, tt.expected, result)
164172
})
165173
}
@@ -392,3 +400,82 @@ func TestExtractPRNumberFromMessage(t *testing.T) {
392400
})
393401
}
394402
}
403+
404+
func TestGetFailedJobURLs(t *testing.T) {
405+
tests := []struct {
406+
name string
407+
runID int64
408+
mockResponse string
409+
expected map[string]string
410+
expectErr bool
411+
}{
412+
{
413+
name: "single failure found",
414+
runID: 123,
415+
mockResponse: `{"jobs": [
416+
{"id": 1, "name": "Success Job", "conclusion": "success", "html_url": "http://job/1"},
417+
{"id": 2, "name": "Failed Job", "conclusion": "failure", "html_url": "http://job/2"}
418+
]}`,
419+
expected: map[string]string{"Failed Job": "http://job/2"},
420+
},
421+
{
422+
name: "multiple failures",
423+
runID: 123,
424+
mockResponse: `{"jobs": [
425+
{"id": 1, "name": "Lint", "conclusion": "failure", "html_url": "http://job/1"},
426+
{"id": 2, "name": "Test-Linux", "conclusion": "failure", "html_url": "http://job/2"},
427+
{"id": 3, "name": "Test-Windows", "conclusion": "success", "html_url": "http://job/3"}
428+
]}`,
429+
expected: map[string]string{"Lint": "http://job/1", "Test-Linux": "http://job/2"},
430+
},
431+
{
432+
name: "timed_out treated as failure",
433+
runID: 123,
434+
mockResponse: `{"jobs": [
435+
{"id": 1, "name": "Success Job", "conclusion": "success", "html_url": "http://job/1"},
436+
{"id": 2, "name": "Timeout Job", "conclusion": "timed_out", "html_url": "http://job/2"}
437+
]}`,
438+
expected: map[string]string{"Timeout Job": "http://job/2"},
439+
},
440+
{
441+
name: "no failures found",
442+
runID: 123,
443+
mockResponse: `{"jobs": [
444+
{"id": 1, "name": "Success Job", "conclusion": "success", "html_url": "http://job/1"}
445+
]}`,
446+
expected: map[string]string{},
447+
},
448+
}
449+
450+
for _, tt := range tests {
451+
t.Run(tt.name, func(t *testing.T) {
452+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
453+
w.WriteHeader(http.StatusOK)
454+
_, err := w.Write([]byte(tt.mockResponse))
455+
require.NoError(t, err)
456+
}))
457+
defer server.Close()
458+
459+
client := &Client{
460+
logger: zaptest.NewLogger(t),
461+
envVariables: map[string]string{
462+
githubOwner: testOwner,
463+
githubRepository: testRepo,
464+
githubServerURL: "https://github.com",
465+
},
466+
}
467+
468+
// Override client base URL to point to mock server
469+
c, _ := github.NewClient(nil).WithEnterpriseURLs(server.URL, server.URL)
470+
client.client = c
471+
472+
failedJobs, err := client.getFailedJobURLs(context.Background(), tt.runID)
473+
if tt.expectErr {
474+
assert.Error(t, err)
475+
} else {
476+
assert.NoError(t, err)
477+
assert.Equal(t, tt.expected, failedJobs)
478+
}
479+
})
480+
}
481+
}

0 commit comments

Comments
 (0)