diff --git a/CONTEXT.md b/CONTEXT.md index ace927a..f2868d3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -114,7 +114,7 @@ The Dashboard renders a two-level expandable tree: - **Repo row**: expand/collapse with `enter`/`space`. When expanded shows Branch section, then Stack rows and standalone PR rows. A Repo opens on its own when its CI starts running or failing (its branch Stoplight, or any PR it has loaded, goes 🟡 or 🔴) and closes again when it goes quiet, so a calm dashboard sits collapsed. This only fires when that state changes, so a Repo the user opened or closed by hand stays that way until its CI moves. Collapsed Repos do not fetch PR runs, so PR-only activity on a collapsed Repo does not open it. - **Branch section**: non-navigable; always rendered above PR rows when a Repo is expanded. - **Stack row**: navigable; expand/collapse with `enter`/`space` to show its member PR rows. Its Stoplight is the most actionable of its members', and `f` and `o` act on the first failing member so a collapsed Stack still exposes what broke. -- **PR row**: navigable; expand/collapse with `enter`/`space` to show that PR's Workflow runs. A PR inside a Stack renders one level deeper and carries its `position/size`. A Review glyph follows the PR number when there is a Review signal. +- **PR row**: navigable; expand/collapse with `enter`/`space` to show that PR's Workflow runs and each one's Jobs. A PR inside a Stack renders one level deeper and carries its `position/size`. A Review glyph follows the PR number when there is a Review signal. Expansion and the cursor stay attached to what a row shows — the Repo by name, a Stack or PR by its number — so they survive PRs opening and closing, config reloads and active-first re-sorting. @@ -130,7 +130,7 @@ Each poll cycle makes the following API calls per enabled Repo: - 1 × `PullRequests.List` - 1 × GraphQL query for Stack membership and Review status, only for an expanded Repo with 1+ open PRs. A host that rejects the Stack fields gets one Review-only retry. A failure here costs only the grouping and Review glyphs — PRs still render. - 1 × `ListRepositoryWorkflowRuns` per open PR (head SHA runs) -- 1 × `ListWorkflowJobs` per branch Workflow Run +- 1 × `ListWorkflowJobs` per Workflow Run, on the branch and on each PR, so an expanded PR row lists its Jobs the way GitHub's checks do Every GET is a conditional request: the Poller keeps one client per token for the life of the app, and each remembers the ETag of the last response per URL. diff --git a/internal/github/client.go b/internal/github/client.go index 48bdd32..5e371a4 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -194,12 +194,14 @@ func New(token string) *Client { // - 1 call to list open PRs // // Expanded (q.Detail true) adds the detail the dashboard actually renders: -// - 1 call per branch workflow run to fetch jobs // - 1 call per PR for its runs (filtered by head SHA) -// - 1 GraphQL call for stack membership, when 2+ PRs are open +// - 1 call per workflow run, on the branch and on each PR, to fetch jobs +// - 1 GraphQL call for stack membership and review state, when a PR is open // -// A repo with 4 workflows and 11 open PRs therefore costs 2 calls collapsed -// and 17 REST plus 1 GraphQL expanded. No ListWorkflows call in either case. +// A repo with 4 workflows, each also running on 11 open PRs, therefore costs +// 2 calls collapsed and 2+4+11+44 = 61 REST plus 1 GraphQL expanded. The ETag +// cache makes every one of those that is unchanged since the last cycle free, +// so a quiet expanded repo costs close to nothing. No ListWorkflows call. func (c *Client) FetchAll(ctx context.Context, q RepoQuery) (RepoData, error) { var stats fetchStats @@ -317,17 +319,8 @@ func (c *Client) fetchBranchRuns(ctx context.Context, q RepoQuery, stats *fetchS // Jobs are only rendered under an expanded repo row, and cost one call // per run, so they are fetched only when that detail is on screen. if q.Detail { - jobs, jobsResp, err := c.gh.Actions.ListWorkflowJobs(ctx, q.Owner, q.Name, run.GetID(), &github.ListWorkflowJobsOptions{}) - stats.observe(jobsResp) - if err != nil { - return nil, "", fmt.Errorf("listing jobs for run %d in %s/%s: %w", run.GetID(), q.Owner, q.Name, err) - } - for _, j := range jobs.Jobs { - wr.Jobs = append(wr.Jobs, Job{ - Name: j.GetName(), - Status: j.GetStatus(), - Conclusion: j.GetConclusion(), - }) + if wr.Jobs, err = c.fetchJobs(ctx, q, run.GetID(), stats); err != nil { + return nil, "", err } } results = append(results, wr) @@ -361,17 +354,37 @@ func (c *Client) fetchRunsForRef(ctx context.Context, q RepoQuery, sha string, s continue } seen[key] = true + jobs, err := c.fetchJobs(ctx, q, run.GetID(), stats) + if err != nil { + return nil, err + } results = append(results, WorkflowRun{ WorkflowName: workflowName(run), Status: run.GetStatus(), Conclusion: run.GetConclusion(), HTMLURL: run.GetHTMLURL(), RunID: run.GetID(), + Jobs: jobs, }) } return results, nil } +// fetchJobs returns a run's jobs: one call per run, which the ETag cache makes +// free while the run is unchanged. Only called for expanded repos. +func (c *Client) fetchJobs(ctx context.Context, q RepoQuery, runID int64, stats *fetchStats) ([]Job, error) { + jobs, resp, err := c.gh.Actions.ListWorkflowJobs(ctx, q.Owner, q.Name, runID, &github.ListWorkflowJobsOptions{}) + stats.observe(resp) + if err != nil { + return nil, fmt.Errorf("listing jobs for run %d in %s/%s: %w", runID, q.Owner, q.Name, err) + } + out := make([]Job, 0, len(jobs.Jobs)) + for _, j := range jobs.Jobs { + out = append(out, Job{Name: j.GetName(), Status: j.GetStatus(), Conclusion: j.GetConclusion()}) + } + return out, nil +} + // noFailedJobsFragment is what GitHub says when rerun-failed-jobs has nothing // to retry individually — every job either passed or never started. Re-running // the whole run is the right call in that case. diff --git a/internal/github/client_test.go b/internal/github/client_test.go index 329e275..ff485e6 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -198,8 +198,8 @@ func TestFetchAllCollapsedCostsTwoCalls(t *testing.T) { } } -// Expanding the same repo pays for the detail it now renders: one job call per -// deduped run, one runs call per open PR. +// Expanding the same repo pays for the detail it now renders: one runs call per +// open PR, and one job call per deduped run, on the branch and on every PR. func TestFetchAllExpandedFetchesDetail(t *testing.T) { var paths []string c := countingServer(t, &paths) @@ -208,9 +208,9 @@ func TestFetchAllExpandedFetchesDetail(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - // 1 branch runs + 2 job calls + 1 PR list + 3 PR run calls. - if len(paths) != 7 { - t.Fatalf("expanded poll made %d calls (%v), want 7", len(paths), paths) + // 1 branch runs + 2 job calls + 1 PR list + 3 PRs × (1 runs + 2 job calls). + if len(paths) != 13 { + t.Fatalf("expanded poll made %d calls (%v), want 13", len(paths), paths) } jobCalls := 0 for _, p := range paths { @@ -218,12 +218,19 @@ func TestFetchAllExpandedFetchesDetail(t *testing.T) { jobCalls++ } } - if jobCalls != 2 { - t.Errorf("job calls = %d, want one per branch run (2)", jobCalls) + if jobCalls != 8 { + t.Errorf("job calls = %d, want one per run: 2 branch + 3 PRs × 2 (8)", jobCalls) } for _, r := range data.BranchRuns { if len(r.Jobs) == 0 { t.Errorf("expanded run %q has no jobs", r.WorkflowName) } } + for _, pr := range data.PRRuns { + for _, r := range pr.Runs { + if len(r.Jobs) == 0 { + t.Errorf("PR #%d run %q has no jobs", pr.PR.Number, r.WorkflowName) + } + } + } }