Repository navigation
Expand file tree
/
Copy pathchecks.go
More file actions
480 lines (438 loc) · 17.9 KB
/
Copy pathchecks.go
File metadata and controls
480 lines (438 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
package main
import (
"context"
"encoding/json"
"os/exec"
"regexp"
"sort"
"sync"
"time"
)
// CheckState is the rolled-up status of a pull request's CI checks. The values
// are ordered by severity (higher = worse) so the worst entry wins a fold: one
// red check among many greens makes the whole PR red.
type CheckState int
const (
StateNeutral CheckState = iota // only skipped/neutral checks, or none configured
StateSuccess // at least one check, all passing
StatePending // at least one check still running/queued
StateFailure // at least one check failed
)
// maxPRPollProjects caps how many projects are queried for PR status on a single
// refresh. Each project costs one `gh` subprocess + network round-trip, so this
// bounds the per-tick request volume even when a loose filter matches many
// repos. Polling is already gated on an active filter (see shouldPollPR); this
// is the safety net for a filter that still matches a lot.
const maxPRPollProjects = 5
// prFetchConcurrency bounds how many `gh` calls run at once. The projects polled
// are few (<= maxPRPollProjects), but running them in parallel keeps a slow
// network from serialising into a refresh-stalling delay.
const prFetchConcurrency = 5
// prFetchTimeout bounds a single `gh pr list` call. It mirrors the bound on
// treetop's git calls: this runs on the dashboard refresh path and a wedged
// network or hung gh must not stall it.
const prFetchTimeout = 4 * time.Second
// prCacheTTL decouples PR polling from the dashboard refresh: the table refreshes
// every couple of seconds (local git + sessions), but gh is network I/O and CI
// status barely changes second-to-second, so a repo's PR status is re-fetched at
// most this often. Cached states are applied on every refresh in between.
const prCacheTTL = 15 * time.Second
// prCacheStale bounds how long a cache entry survives without a refresh before
// it's swept. A polled repo refreshes its entry every prCacheTTL, so anything
// much older belongs to a project no longer in view; sweeping keeps the cache
// roughly the size of the active set rather than growing across a long session.
const prCacheStale = 10 * prCacheTTL
// ghRollupEntry is one entry of a PR's statusCheckRollup. GitHub mixes two entry
// shapes with different field names; both are normalised by checkStateOf:
// - CheckRun (GitHub Actions): __typename "CheckRun", status + conclusion,
// named by name (the job) under workflowName (the workflow, e.g. "ci")
// - StatusContext (external CI): __typename "StatusContext", state, named by
// context
type ghRollupEntry struct {
Typename string `json:"__typename"`
Status string `json:"status"` // CheckRun: QUEUED/IN_PROGRESS/COMPLETED
Conclusion string `json:"conclusion"` // CheckRun: SUCCESS/FAILURE/... (empty until COMPLETED)
State string `json:"state"` // StatusContext: SUCCESS/PENDING/FAILURE/ERROR/EXPECTED
Name string `json:"name"` // CheckRun: the job/check name
WorkflowName string `json:"workflowName"` // CheckRun: the workflow name
Context string `json:"context"` // StatusContext: the check name
DetailsURL string `json:"detailsUrl"` // CheckRun: link to the run/job page
TargetURL string `json:"targetUrl"` // StatusContext: link to the external check
}
// Check is a single CI check (one statusCheckRollup entry), retained for the
// per-check rows the --checks view expands beneath a worktree. The rollup glyph
// folds these into one worst-wins state; this keeps the individual name + state.
type Check struct {
Name string
State CheckState
URL string // link to the check's run page (detailsUrl/targetUrl); may be empty
Skipped bool // the check was skipped for this change (a no-op, distinct from neutral)
}
// ghPR is one open pull request as returned by `gh pr list --json`.
type ghPR struct {
Number int `json:"number"`
HeadRefName string `json:"headRefName"`
IsCrossRepository bool `json:"isCrossRepository"`
IsDraft bool `json:"isDraft"`
ReviewDecision string `json:"reviewDecision"` // APPROVED/CHANGES_REQUESTED/REVIEW_REQUIRED, or "" when none required
URL string `json:"url"` // the PR's html URL, for the clickable PR link
Rollup []ghRollupEntry `json:"statusCheckRollup"`
}
// PRReview is a pull request's review state, used to colour the PR-number suffix.
// Only open PRs are fetched, so there are no closed/merged values: the states are
// draft, the three GitHub review decisions, and "open with no decision yet".
type PRReview int
const (
ReviewNone PRReview = iota // open, no review decision (or none required)
ReviewDraft // a draft PR — not yet up for review
ReviewRequired // review requested, still undecided
ReviewApproved // at least one approval, none outstanding
ReviewChangesRequested // a reviewer requested changes
)
// prReviewOf maps a PR's draft flag and review decision to a PRReview. Draft wins
// over the decision: a draft isn't really up for review, so it reads as draft
// regardless of any (usually empty) decision.
func prReviewOf(pr ghPR) PRReview {
if pr.IsDraft {
return ReviewDraft
}
switch pr.ReviewDecision {
case "APPROVED":
return ReviewApproved
case "CHANGES_REQUESTED":
return ReviewChangesRequested
case "REVIEW_REQUIRED":
return ReviewRequired
default:
return ReviewNone
}
}
// checkStateOf normalises a single rollup entry into a CheckState, folding the
// two GitHub entry shapes (CheckRun, StatusContext) into one enum.
func checkStateOf(e ghRollupEntry) CheckState {
// A CheckRun that hasn't completed has no conclusion yet: it's pending,
// regardless of the (empty) conclusion field.
if e.Typename == "CheckRun" && e.Status != "COMPLETED" {
return StatePending
}
v := e.Conclusion
if v == "" {
v = e.State // StatusContext (or a CheckRun with an unexpected empty conclusion)
}
switch v {
case "FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE":
return StateFailure
case "PENDING", "QUEUED", "IN_PROGRESS", "EXPECTED":
return StatePending
case "SUCCESS":
return StateSuccess
default: // SKIPPED, NEUTRAL, STALE, or anything unrecognised
return StateNeutral
}
}
// checkSkipped reports whether a rollup entry was skipped — a check configured
// for the repo that didn't run for this change (e.g. a path-filtered job). It
// folds into StateNeutral for the worst-wins rollup, so it can't be told apart
// by state alone; the per-check view ("checks", skipped hidden) needs the
// distinction, hence this separate flag. A CheckRun reports it via conclusion;
// a StatusContext has no skipped state, so it's never skipped.
func checkSkipped(e ghRollupEntry) bool {
return e.Typename == "CheckRun" && e.Status == "COMPLETED" && e.Conclusion == "SKIPPED"
}
// rollupCheckState folds a PR's rollup entries into a single worst-wins state.
// An empty rollup is StateNeutral, never StateSuccess: a PR with no configured
// checks must not masquerade as passing.
func rollupCheckState(entries []ghRollupEntry) CheckState {
worst := StateNeutral
for _, e := range entries {
if s := checkStateOf(e); s > worst {
worst = s
}
}
return worst
}
// checkName picks the most useful display name for a rollup entry, which varies
// by entry shape: a CheckRun carries a job name (under a workflow name), a
// StatusContext carries a context. Falls back across the fields, then to a
// generic label, so a per-check row always has something to show.
func checkName(e ghRollupEntry) string {
switch {
case e.Name != "":
return e.Name
case e.Context != "":
return e.Context
case e.WorkflowName != "":
return e.WorkflowName
default:
return "check"
}
}
// checkURLOf picks a rollup entry's link: a CheckRun's detailsUrl (its GitHub
// Actions run/job page) or a StatusContext's targetUrl (the external CI's page).
// Either may be empty — not every check publishes a URL — in which case the
// check's row simply isn't made clickable.
func checkURLOf(e ghRollupEntry) string {
if e.DetailsURL != "" {
return e.DetailsURL
}
return e.TargetURL
}
// rollupChecks expands a PR's rollup entries into per-check rows for the --checks
// view, sorted worst-first (failures lead) then by name. The ordering puts the
// rows a user cares about on top and stays stable across refreshes so the
// expanded block doesn't reshuffle on every poll. Returns nil for an empty
// rollup (a PR with no configured checks expands to nothing).
//
// Entries are deduplicated by display name, folding to the worst state — the
// same worst-wins rule the glyph uses, so a check's row and its contribution to
// the glyph always agree. This matters because GitHub's statusCheckRollup can
// carry two CheckRuns with the same name (e.g. after a PR is closed and
// reopened, a stale run lingers beside the fresh one); without folding, the same
// check would show as two rows.
func rollupChecks(entries []ghRollupEntry) []Check {
if len(entries) == 0 {
return nil
}
byName := make(map[string]int, len(entries)) // name -> index in checks
checks := make([]Check, 0, len(entries))
for _, e := range entries {
name, state, url := checkName(e), checkStateOf(e), checkURLOf(e)
skipped := checkSkipped(e)
if i, ok := byName[name]; ok {
checks[i].State = max(checks[i].State, state) // worst wins
// A folded check is skipped only when every entry was skipped: one real
// run beside a stale skipped run means the check actually ran.
checks[i].Skipped = checks[i].Skipped && skipped
if checks[i].URL == "" { // keep the first link we saw
checks[i].URL = url
}
continue
}
byName[name] = len(checks)
checks = append(checks, Check{Name: name, State: state, URL: url, Skipped: skipped})
}
sort.SliceStable(checks, func(i, j int) bool {
if checks[i].State != checks[j].State {
return checks[i].State > checks[j].State // worst first
}
return checks[i].Name < checks[j].Name
})
return checks
}
// prStatus is a branch's PR check status: the PR number, the rolled-up state
// behind the glyph, and the individual checks behind the --checks rows. All come
// from one rollup, so they stay consistent and cost no extra gh call.
type prStatus struct {
Number int
URL string
State CheckState
Review PRReview
Checks []Check
}
// ghFetchPRChecks runs `gh pr list` in dir and returns a map from each open PR's
// head branch to its rolled-up CheckState. ok is false (with the map nil) when
// gh is missing, unauthenticated, the directory has no GitHub remote, or the call
// times out: PR status is best-effort enrichment and must never break a refresh.
// A successful call with no open PRs returns an empty map with ok true, so the
// cache can remember "this repo has none" rather than retrying every tick.
func ghFetchPRChecks(dir string) (map[string]prStatus, bool) {
ctx, cancel := context.WithTimeout(context.Background(), prFetchTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "gh", "pr", "list",
"--state", "open", "--limit", "100",
"--json", "number,url,headRefName,isCrossRepository,isDraft,reviewDecision,statusCheckRollup")
cmd.Dir = dir
// gh shells out to git to resolve the repo from its remote. Harden that child
// git the same way git.go does: a scanned repo is untrusted, and git runs
// config-named programs (notably core.fsmonitor) during ordinary operations.
cmd.Env = hardenedGitEnv()
out, err := cmd.Output()
if err != nil {
return nil, false
}
var prs []ghPR
if err := json.Unmarshal(out, &prs); err != nil {
return nil, false
}
checks := make(map[string]prStatus, len(prs))
for _, pr := range prs {
if pr.HeadRefName == "" {
continue
}
// Map by head branch only. A local worktree's branch lives in this repo,
// so its PR is a same-repo one; skipping cross-repository PRs (from forks)
// both matches that intent and avoids a branch-name collision overwriting
// the right PR — GitHub allows only one open PR per same-repo head branch.
if pr.IsCrossRepository {
continue
}
checks[pr.HeadRefName] = prStatus{
Number: pr.Number,
URL: pr.URL,
State: rollupCheckState(pr.Rollup),
Review: prReviewOf(pr),
Checks: rollupChecks(pr.Rollup),
}
}
return checks, true
}
type prCacheEntry struct {
at time.Time
checks map[string]prStatus
}
var (
prCacheMu sync.Mutex
prCache = map[string]prCacheEntry{}
)
// fetchPRChecks returns a repo's branch->CheckState map, served from a
// prCacheTTL cache so gh is hit at most once per TTL per repo regardless of how
// often the dashboard refreshes. On a fetch failure it falls back to the last
// cached value (stale is better than blank), or nil if there is none.
func fetchPRChecks(dir string) map[string]prStatus {
now := time.Now()
prCacheMu.Lock()
if e, ok := prCache[dir]; ok && now.Sub(e.at) < prCacheTTL {
prCacheMu.Unlock()
return e.checks
}
prCacheMu.Unlock()
checks, ok := ghFetchPRChecks(dir)
if !ok {
// Fetch failed: keep showing the last good data if we have any.
prCacheMu.Lock()
defer prCacheMu.Unlock()
return prCache[dir].checks
}
prCacheMu.Lock()
prCache[dir] = prCacheEntry{at: now, checks: checks}
for path, e := range prCache { // sweep entries for repos no longer in view
if now.Sub(e.at) > prCacheStale {
delete(prCache, path)
}
}
prCacheMu.Unlock()
return checks
}
// enrichPRChecks stamps PR check status onto the worktrees of the first
// maxPRPollProjects projects (already sorted by name, so the selection is
// deterministic). It returns the number of projects polled, so the caller can
// tell the user when a filter matched more projects than were polled.
//
// Projects are queried concurrently (bounded by prFetchConcurrency) because each
// gh call is network-bound; serialising them could push a refresh past its
// interval.
func enrichPRChecks(projects []Project) (polled int) {
polled = len(projects)
if polled > maxPRPollProjects {
polled = maxPRPollProjects
}
sem := make(chan struct{}, prFetchConcurrency)
var wg sync.WaitGroup
for i := 0; i < polled; i++ {
p := projects[i]
if len(p.Worktrees) == 0 {
continue
}
wg.Add(1)
go func(p Project) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
// Any worktree path works: gh resolves the repo from the remote.
checks := fetchPRChecks(p.Worktrees[0].Path)
for j := range p.Worktrees {
if s, ok := checks[p.Worktrees[j].Branch]; ok {
p.Worktrees[j].Check = s.State
p.Worktrees[j].Checks = s.Checks
p.Worktrees[j].PRNumber = s.Number
p.Worktrees[j].PRURL = s.URL
p.Worktrees[j].PRReview = s.Review
p.Worktrees[j].HasPR = true
}
}
}(p)
}
// Refresh the gh-health signal on the same (background) refresh path, so the
// header can explain a blank column when gh is missing or unauthenticated
// rather than leaving the user guessing. Cached, so it's at most one extra
// `gh auth status` per ghHealthTTL.
refreshGHHealth()
wg.Wait()
return polled
}
// ghHealthTTL bounds how often the gh-health probe runs. Auth state rarely
// changes, so a stale-by-this-much answer is fine and spares a subprocess on
// every refresh.
const ghHealthTTL = 30 * time.Second
var (
ghHealthMu sync.Mutex
ghHealthAt time.Time
ghHealthNote string // "" when gh is usable; otherwise a header-ready note
)
// refreshGHHealth probes whether gh is usable (installed + authenticated) and
// caches a header note describing the problem, or "" when healthy. It runs on
// the refresh goroutine; the render loop only ever reads the cached note via
// ghProblemNote, so it never blocks the UI on a subprocess.
func refreshGHHealth() {
ghHealthMu.Lock()
fresh := !ghHealthAt.IsZero() && time.Since(ghHealthAt) < ghHealthTTL
ghHealthMu.Unlock()
if fresh {
return
}
note := probeGHHealth()
ghHealthMu.Lock()
ghHealthNote, ghHealthAt = note, time.Now()
ghHealthMu.Unlock()
}
// probeGHHealth returns "" when gh is installed and authenticated, otherwise a
// note explaining which is missing. A missing binary is checked first (cheap,
// no subprocess); auth is verified with `gh auth status`, which exits non-zero
// when no host is logged in.
func probeGHHealth() string {
if _, err := exec.LookPath("gh"); err != nil {
return "PR checks: gh not found on PATH — install the GitHub CLI"
}
ctx, cancel := context.WithTimeout(context.Background(), prFetchTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "gh", "auth", "status")
cmd.Env = hardenedGitEnv()
if err := cmd.Run(); err != nil {
return "PR checks: gh not authenticated — run `gh auth login`"
}
return ""
}
// ghProblemNote returns the cached gh-health note (empty when gh is usable or
// hasn't been probed yet). Safe to call from the render loop: it only reads.
func ghProblemNote() string {
ghHealthMu.Lock()
defer ghHealthMu.Unlock()
return ghHealthNote
}
// shouldPollPR reports whether PR status should be fetched: the --pr flag is set
// and the project list has been narrowed by some filter. Without a filter, --pr
// is dormant — polling every repo under $HOME on each tick would be a request
// storm. live is the compiled live-mode filter (watch's "/" box); the other
// filters come from opts.
func shouldPollPR(opts options, live []*regexp.Regexp) bool {
return opts.pr && prFilterActive(opts, len(live) > 0)
}
// prFilterActive reports whether any project-narrowing filter is in effect.
// liveActive is whether the watch-mode live filter currently holds a query.
func prFilterActive(opts options, liveActive bool) bool {
return len(opts.patterns) > 0 || liveActive || opts.onlyInUse || opts.onlyOpen
}
// projectWorstCheck folds a project's worktree PR states into one worst-wins
// state for the compact (one-line-per-project) view. ok is false when no
// worktree in the project has a PR.
func projectWorstCheck(p Project) (state CheckState, ok bool) {
for _, w := range p.Worktrees {
if !w.HasPR {
continue
}
if !ok || w.Check > state {
state, ok = w.Check, true
}
}
return state, ok
}