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
4 changes: 2 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
43 changes: 28 additions & 15 deletions internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 14 additions & 7 deletions internal/github/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -208,22 +208,29 @@ 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 {
if strings.Contains(p, "/jobs") {
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)
}
}
}
}
Loading