diff --git a/CONTEXT.md b/CONTEXT.md index 8342ffa..ace927a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,6 +8,7 @@ A terminal dashboard that shows live GitHub CI status across multiple repos, upd _Avoid_: project, service **Workflow**: The primary display unit. A named CI workflow within a Repo (e.g. `CI`, `Deploy`). Shows the status of its latest Run. +GitHub's built-in CodeQL scanning (its default setup) counts as a Workflow named `CodeQL`, since it is often a Repo's only one. Its other built-ins — Dependabot's update jobs and Copilot's PR reviews — are not CI and are never shown. _Avoid_: pipeline, action **Run**: A single execution of a Workflow, triggered by a push, PR, or manual dispatch. Has a status (queued, in_progress, success, failure, cancelled). diff --git a/internal/github/client.go b/internal/github/client.go index e4c7ad6..48bdd32 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -114,11 +114,29 @@ type RepoQuery struct { Detail bool } -// dependabotEvent is the event GitHub assigns to the runs Dependabot generates -// for its own update jobs. Those runs are named after the update -// ("npm_and_yarn in /. for axios, ...") rather than after a workflow, so they -// never collapse in a name-keyed dedupe and can swamp a repo's branch section. -const dependabotEvent = "dynamic" +// dynamicEvent is the event GitHub assigns to runs of its built-in workflows: +// Dependabot's update jobs, Copilot's PR reviews and CodeQL's default setup. +// Only CodeQL is CI signal, and it is often a repo's only workflow. Dependabot +// names each run after the update ("npm_and_yarn in /. for axios, ...") and +// can swamp a repo's branch section, so it and Copilot stay hidden. +const dynamicEvent = "dynamic" + +// codeQLPath is the workflow path prefix of CodeQL's default setup. +const codeQLPath = "dynamic/github-code-scanning/" + +func isCodeQL(run *github.WorkflowRun) bool { + return run.GetEvent() == dynamicEvent && strings.HasPrefix(run.GetPath(), codeQLPath) +} + +// workflowName is the name a run's Workflow row shows. CodeQL names each run +// after its trigger ("Push on main", "PR #121", "Scheduled"), so it is shown +// by its workflow name instead. +func workflowName(run *github.WorkflowRun) string { + if isCodeQL(run) { + return "CodeQL" + } + return run.GetName() +} func newFilterSet(workflows []string) map[string]bool { set := make(map[string]bool, len(workflows)) @@ -138,14 +156,14 @@ func runKey(run *github.WorkflowRun) string { return "name:" + run.GetName() } -// keepRun reports whether a run belongs in the dashboard: Dependabot's own -// update runs are dropped, and when the query names workflows only those are -// kept. +// keepRun reports whether a run belongs in the dashboard: GitHub's built-in +// runs other than CodeQL are dropped, and when the query names workflows only +// those are kept. func keepRun(run *github.WorkflowRun, filterSet map[string]bool) bool { - if run.GetEvent() == dependabotEvent { + if run.GetEvent() == dynamicEvent && !isCodeQL(run) { return false } - if len(filterSet) > 0 && !filterSet[run.GetName()] { + if len(filterSet) > 0 && !filterSet[workflowName(run)] { return false } return true @@ -289,7 +307,7 @@ func (c *Client) fetchBranchRuns(ctx context.Context, q RepoQuery, stats *fetchS seen[key] = true wr := WorkflowRun{ - WorkflowName: run.GetName(), + WorkflowName: workflowName(run), Status: run.GetStatus(), Conclusion: run.GetConclusion(), HTMLURL: run.GetHTMLURL(), @@ -344,7 +362,7 @@ func (c *Client) fetchRunsForRef(ctx context.Context, q RepoQuery, sha string, s } seen[key] = true results = append(results, WorkflowRun{ - WorkflowName: run.GetName(), + WorkflowName: workflowName(run), Status: run.GetStatus(), Conclusion: run.GetConclusion(), HTMLURL: run.GetHTMLURL(), diff --git a/internal/github/client_test.go b/internal/github/client_test.go index d4dec40..329e275 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -28,6 +28,47 @@ func TestKeepRunDropsDependabotUpdates(t *testing.T) { } } +func dynamicRun(name, path string) *github.WorkflowRun { + r := run(name, "dynamic", 7) + r.Path = github.Ptr(path) + return r +} + +// GitHub's built-in workflows all run with event "dynamic". CodeQL is real CI +// signal and is often a repo's only workflow; Dependabot's update runs and +// Copilot's PR reviews are not. +func TestKeepRunKeepsCodeQLButNotOtherBuiltIns(t *testing.T) { + cases := []struct { + run *github.WorkflowRun + keep bool + }{ + {dynamicRun("Push on main", "dynamic/github-code-scanning/codeql"), true}, + {dynamicRun("PR #121", "dynamic/github-code-scanning/codeql"), true}, + {dynamicRun("npm_and_yarn in /. for axios", "dynamic/dependabot/dependabot-updates"), false}, + {dynamicRun("Copilot code review", "dynamic/copilot-pull-request-reviewer/copilot-pull-request-reviewer"), false}, + } + for _, tc := range cases { + if got := keepRun(tc.run, nil); got != tc.keep { + t.Errorf("%s (%s): keep = %v, want %v", tc.run.GetName(), tc.run.GetPath(), got, tc.keep) + } + } +} + +// CodeQL names each run after its trigger, so the Workflow row needs a stable +// name, and the workflows filter matches on it. +func TestCodeQLRunsAreNamedCodeQL(t *testing.T) { + r := dynamicRun("Scheduled", "dynamic/github-code-scanning/codeql") + if got := workflowName(r); got != "CodeQL" { + t.Errorf("workflowName = %q, want CodeQL", got) + } + if got := workflowName(run("CI", "push", 1)); got != "CI" { + t.Errorf("workflowName = %q, want CI", got) + } + if !keepRun(r, newFilterSet([]string{"CodeQL"})) { + t.Error("a workflows filter naming CodeQL must keep its runs") + } +} + func TestKeepRunHonoursWorkflowFilter(t *testing.T) { filter := newFilterSet([]string{"CI"}) if !keepRun(run("CI", "push", 1), filter) {