Skip to content
Merged
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
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
42 changes: 30 additions & 12 deletions internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
41 changes: 41 additions & 0 deletions internal/github/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading