Live status of the current branch's GitHub pull request in Claude Code: checks, reviews, threads, mergeability, merge queue, with confirmed merge actions.

The pull request status of your current branch, its repository's issues and its GitHub Actions runs, live inside Claude Code, so you never open GitHub just to check them.
<img src="docs/images/tab-view.png" alt="gh-status in Claude Code: a one-line band above the prompt and a detail pane showing the merge verdict, reviews, open threads and the live pipeline" width="100%">
/gh (or /pr). Merge section, reviews, conversation, and the full pipeline. Comment times read @bot 4 min ago, or @bot edited 2 min ago once a comment was edited in place (review bots do that).ready to merge · 1 check failed is red, it is yellow while checks run, and it is green only when everything is clean.1: PRs 2: Issues 3: Actions. Issues lists the repository's open issues, Actions its workflow runs, each with a detail view. Both are read only, except that a failed run can be re-run./gh. One command for the three tabs: /gh, /gh issues, /gh actions, /gh <number>, /gh run <id>.main ✗ <workflow>.gh login, with no gh auth switch.The pane always starts with a tab row:
1: PRs 2: Issues 3: Actions
The digits 1, 2 and 3 switch tabs while the pane has the keyboard, and so does a click. The row shows the number of open pull requests and issues, and a symbol for the default branch's workflows on the Actions tab (✗ failing, ● running, ✓ passing).
b or l go up one level in the same tab, from a detail to its list, with the cursor back on the row you came from.b there goes to that tab's list.n and p page.| Entry point | Opens |
|---|---|
/gh with a pull request on the branch | The PRs tab, showing that pull request |
/gh without one | The PRs tab, showing the list |
/gh issues (i) | The Issues tab, at the level it was left (the list the first time) |
/gh actions (ac) | The Actions tab, at the level it was left (the list the first time) |
/gh prs, /gh list, /gh ls | The PRs tab, showing the list |
/gh <n> | Pull request n in the PRs tab; if there is no such pull request, issue n in the Issues tab |
/gh issue <n> | Issue n in the Issues tab |
/gh run <id> | Run id in the Actions tab |
l: list in the band | The PRs tab, showing the list |
main ✗ notice in the band | The Actions tab (hotkey 3 while the band has the keyboard) |
<img src="docs/images/compact-view.png" alt="The detail pane opened inline above the prompt in a narrow terminal" width="70%">
Below 110 columns, or outside the fullscreen layout, the pane opens inline above the prompt instead of docking beside the transcript.
<img src="docs/images/confirm.png" alt="The merge confirmation in the pane's Merge section and in the band, with a merge method selector" width="100%">
Merge, add to and remove from the merge queue, auto-merge on and off, admin merge, and the review actions all ask first. Nothing is written until you press the confirm key.
--match-head-commit, queue and auto-merge use expectedHeadOid, so GitHub refuses if the branch moved.commitOID. A review whose result is unknown (a timeout or a server error) is never retried.--delete-branch and --auto are never used.<img src="docs/images/band.png" alt="The one-line band: PR number, open badge, verdict, failed check and running jobs" width="100%">
The band is one line above the prompt: PR number and state badge, the verdict (the first caveat such as a failed check, running checks or an unresolved thread, or blocked, in queue, auto-merge), the pipeline, reviews, unresolved threads and the account. A second row lists further blockers.
When the branch has no pull request, the band reads no PR for <branch> · N open · N yours · N review requested with a list button. With a pull request it shows a dim N open at the end of the row, dropped first when the row is tight. The counts, which include open issues, are read every 5 minutes.
When the newest run of a workflow on the default branch failed, the band also shows main ✗ <workflow> (with +N for further failing workflows). It is a button that opens the Actions tab. It is read every 5 minutes with a conditional request, and the setting mainStatus (red by default, off) turns it off.
Keys, when the band has the keyboard (a key only appears when its action is available):
| Key | Does |
|---|---|
d | Open the detail pane |
o | Open the pull request on GitHub |
f | Fix CI with Claude (when a check failed) |
m | Merge |
q | Add to the merge queue |
l | Open the list of open pull requests (when the branch has no pull request) |
3 | Open the Actions tab (when the main ✗ notice is shown) |
y / n | Confirm / cancel a pending confirmation |
<img src="docs/images/list-view.png" alt="The list pane: the repository's open pull requests in groups, two lines each with status symbol, title, labels, number, author, age, checks, review state and comments" width="750">
Open the list with l in the band (when the branch has no pull request) or in the pane, with /gh list (ls), or with /gh alone when the branch has no pull request. The list and the detail of a pull request are two levels of the PRs tab.
✓ passing, ✗ failing, ● running, ◷ queued), the title and the labels in a muted version of their GitHub color. The second has the number, author, age, checks, review state and comment count.n next, p previous.h hides or shows them.r refreshes now.<img src="docs/images/selected-view.png" alt="The detail pane showing a pull request picked from the list, with a back key in its header" width="750">
Press Enter on a row in the list, or type /gh <number>. The same pane switches to that pull request, with b: back to list in its header. b (or l) returns to the list with the focus on the row you came from; the page and the draft filter stay. /gh back does the same. The band keeps showing the branch's own pull request.
Merging, the queue and the other actions act on the pull request the pane shows, with the same confirmation and bindings as for the branch's pull request. Closing the pane ends the selection.
In the pane, for the pull request it shows (the branch's or a selected one):
v approve, with an optional comment.w request changes. The text is required.e ready for review, for your own drafts.The commands are /gh approve, /gh changes and /gh ready. Each opens the pane with a confirmation that shows the repository, the number, the head commit, the account and the exact text that will be posted.
y to post or n to cancel.<img src="docs/images/issues-view.png" alt="The Issues tab: the repository's open issues in groups, two lines each with title, labels, number, author, age, assignees, comments and linked pull requests" width="750">
The Issues tab lists the repository's open issues.
n next, p previous. The list is read again while the tab shows it, and r refreshes now.<img src="docs/images/issue-view.png" alt="The detail of an issue: state badge, labels, assignees, milestone, linked pull requests, description and the latest comments" width="750">
Enter opens an issue. The detail shows:
o opens the issue on GitHub for the rest.o opens the issue on GitHub, c copies its URL and r refreshes. Issues are read only: gh-status does not write to them.
<img src="docs/images/actions-view.png" alt="The Actions tab: workflow runs in groups, two lines each with state symbol, workflow name, branch, run number, event, actor, age and duration" width="750">
The Actions tab lists workflow runs.
n next, p previous, r refreshes now.<img src="docs/images/run-view.png" alt="The detail of a workflow run: state, branch, event, actor and commit, timing and the jobs with their running or failed step" width="750">
Enter opens a run. The detail shows the branch, event, actor, commit, attempt and timing, and the run's jobs, with the running or failed step of each, ordered like the pull request's pipeline. A run can also be opened from a pull request's pipeline.
For a failed or timed-out run:
t re-runs the failed jobs of that run (gh run rerun <id> --failed). There is no confirmation, as for the pull request's t: it only starts CI again.f puts a reference to the failure into the prompt, never the log.o opens the run on GitHub, c copies its URL and r refreshes.
In a terminal Claude Code session:
/plugin install gh-status --marketplace oborchers/claude-gh-status
Answer y to add the marketplace, then pick a scope (user scope is listed first). The command works in a terminal session only.
Update:
claude plugin update gh-status@claude-gh-status
Then run /reload-plugins in sessions that are already running.
Requirements:
gh, logged in (gh auth login)The command is /pr, and /gh does the same (it is skipped without a message if another command already has that name). If a skill or another command already uses /pr in a session, the mod does not register it there and a toast says so; /gh-pr always works. The name can be changed with the setting command (Command name). Below, /gh stands for any of these names.
| Command | Does |
|---|---|
/gh | Show the branch's pull request in the pane (the list, when the branch has no pull request) |
/gh issues (/gh i) | Open the Issues tab |
/gh actions (/gh ac) | Open the Actions tab |
/gh issue <n> | Show issue n in the Issues tab |
/gh run <id> | Show a workflow run in the Actions tab |
/gh refresh | Refresh now |
/gh open | Open the pull request on GitHub |
/gh copy | Copy the pull request URL |
/gh close | Close the pane (and drop a pending confirmation) |
/gh prs (/gh list, /gh ls) | Open the list of open pull requests |
/gh <number> | Show that pull request in the pane; if there is none, that issue |
/gh back | Up one level in the active tab |
/gh approve | Ask to approve (optional comment) |
/gh changes | Ask to request changes (text required) |
/gh ready | Ask to mark your own draft ready for review |
/gh merge (/gh m) | Ask to merge |
/gh queue (/gh q) | Ask to add the PR to the merge queue |
/gh dequeue (/gh dq) | Ask to remove the PR from the merge queue |
/gh auto (/gh a) | Ask to turn auto-merge on, or off if it is on |
/gh admin (/gh x) | Ask for an admin merge (two steps) |
/gh fix | Put the failed CI jobs into the prompt |
/gh rerun (/gh rr) | Re-run only the failed jobs of the failed workflow runs (no confirmation: it only starts CI again) |
/gh refresh refreshes the tab the pane shows. Write commands (merge, queue, auto-merge, admin merge, approve, changes, ready) only ask: they open the detail pane with the keyboard and show the confirmation in its Merge section and in the band. The keyboard is granted when the prompt is empty. They tell you when an action is not available for the current PR.
Re-running covers the head commit's failed, timed-out and cancelled Actions runs, once each, with gh run rerun <id> --failed. A run that still has a queued or running job is skipped (GitHub refuses it), runs waiting for approval are skipped, and the merge queue's own checks are never touched. It writes nothing but a new CI attempt: no merge, no code.
Keys (a key only appears when its action is available):
| Where | Keys |
|---|---|
| Band | l list (without a pull request), 3 Actions (with the main ✗ notice), d details, o open on GitHub, f fix CI (when a check failed), m merge, q add to queue, t retry failed jobs (when a failed run can be re-run) |
| Pane, all tabs | 1 PRs, 2 Issues, 3 Actions, b or l up one level in the Issues and Actions tabs, n next page, p previous page, r refresh |
| Issues and Actions lists | up and down move between items, Enter or a click opens one, n and p page, r refresh |
| Issue detail | o open on GitHub, c copy URL, r refresh |
| Run detail | o open on GitHub, c copy URL, r refresh, t retry failed jobs and f fix CI (for a failed run) |
| PR list | up and down move between pull requests, Enter or a click opens one in the same pane, n next page, p previous page, h hide or show drafts, r refresh, x close the pane |
| PR pane | m merge, q add to or remove from queue, a auto-merge on or off, x admin merge, v approve, w request changes, e ready for review, l list (l or b back to the list while a picked pull request is shown), o open on GitHub, c copy URL, r refresh, f fix CI (when a check failed), t retry failed jobs (when a failed run can be re-run) |
| Confirmation (band or pane) | y confirm (b for the final admin step), n cancel; in a review's text field, Esc leaves the field first |
Keys work while the band or pane has the keyboard: focus the band or pane with ctrl+x tab or a click. If your terminal multiplexer takes ctrl+x tab, click the band to focus it. Esc returns to the prompt. There is no separate dialog: a button press or a slash command sets the confirmation, and both the band (a one-line prompt with y and n) and the pane's Merge section (full details) show it, so whichever one holds the keyboard can answer. Cancel with n (or /pr close, or the pane's close mark); Esc alone only returns the keyboard. With several allowed merge methods the pane's confirmation shows a method selector (squash is the default if allowed).
When the repository is private and the account is not your active gh login, opening the pull request on GitHub also shows a hint to sign in to github.com as that account, because the browser would otherwise show a 404.
The account for a repository is chosen in this order:
accounts setting for the repository owner.credential.helper that calls gh auth token -u <login>.gh login, if it can read the repository.gh accounts on github.com, each probed for read access; the first that works is remembered.The account's token goes only to the single gh child process that needs it (as GH_TOKEN). gh-status never runs gh auth switch, so your global gh login stays as it is. Tokens are kept in memory only.
Shown as rows in /config.
| Setting | Default | Meaning |
|---|---|---|
Account per owner (accounts) | empty | Comma separated owner=login pairs, e.g. my-org=work-login,my-user=my-user. Wins over automatic detection. |
Fast interval (fastIntervalSeconds) | 5 | Check interval while checks run or the PR is in the merge queue. |
Idle interval (idleIntervalSeconds) | 30 | Check interval when nothing runs. |
Toasts on state changes (toasts) | on | Show a toast when the pipeline, reviews, comments or mergeability change. |
Command name (command) | pr | Slash command name (1 to 32 characters of a-z, 0-9 and -; anything else means pr). When it is taken in a session, for example by a skill, /gh-pr is used instead. |
Main branch status in the band (mainStatus) | red | red shows main ✗ <workflow> in the band while a workflow of the default branch fails; off hides it and stops reading it. |
Pane width (paneWidthPercent) | 35 | Share of the terminal width the docked pane opens with, at least 60 columns. 0 leaves it to Claude Code. A width you drag the pane to wins. |
Color scheme (colors) | claude | claude follows your Claude Code theme; github uses GitHub's Primer colors. |
All Claude Code sessions of one GitHub account share GitHub's hourly budget: 5,000 GraphQL points and 5,000 REST requests. Requests answered "not modified" (304) are not counted. Only the visible tab is read. Below 500 remaining points the mod reads at most once a minute.
304 Not Modified answer does not count against the REST rate limit.git push, pull, checkout, switch, rebase, merge, commit, reset and gh pr or gh run commands run by Claude, when a turn ends, when the working directory changes, and on /pr.main ✗ status is read every 5 minutes with a conditional request.toasts).-p runs or in cloud sessions.claude --plugin-dir <path> # load from disk, hot reload on save
claude plugin validate .
tsc -p .
claude plugin test .
To refresh the screenshots in docs/images, use the demo skill in .claude/skills/gh-status-demo. It sets up a demo pipeline, branch and pull request, and the terminal layouts for the tab and compact views.
Architecture:
hooks/register.tsx is the only file that touches $ (the engine interface); it wires commands, hooks and state.src/*.ts modules are pure and receive their effects (gh, git, state, time, toasts) as injected functions.src/ui/*.tsx draws the band and the pane (tab row, the three tabs and the inline confirmation) from a view built by register.tsx.types/index.d.ts.tests/.MIT, see LICENSE.
hooks/register.tsx 1619 lines1import { atom, read, update } from 'claude-code'
2import type { CommandInfo, EngineInterface, PluginOptions, Register, RenderElement, RenderSurface, Timer, UiOpenResult } from 'claude-code'
3
4import { CHANGES_NEED_TEXT, RECHECK_FAILED, RULES_CHANGED, TEXT_CHANGED, availableActions, bypassedRules, buildPending, canRerunRun, ciReference, clampReviewText, confirmStep, execute, executeRerun, isReviewTextMissing, isTooEarly, repoSlug, rerunnableRunIds, transitionToastPrefix, withMethod } from '../src/actions'
5import type { ActionKind } from '../src/actions'
6import { fastCheck } from '../src/etag'
7import { runGh, tokenFor } from '../src/gh'
8import type { Exec } from '../src/gh'
9import { parseAccountConfig, resolveAccount } from '../src/accounts'
10import type { AccountMemory } from '../src/accounts'
11import { readLocation } from '../src/git'
12import { fetchJobs } from '../src/jobs'
13import { fetchIssue, fetchIssues, issueAfter, issueProblemOf, issuePageSize, issueRowCount, issuesAfter, issuesProblemOf, lastIssuePage } from '../src/issues'
14import { COUNTS_POLL_MS, COUNTS_RETRY_MS, LIST_POLL_MS, PAGE_SIZE, fetchCounts, fetchList, lastPage, listAfter, listDelayMs, listPageSize, listRowCount, pollDelayMs, rebasePage } from '../src/list'
15import type { AccountChoice, IssueList, IssueSnapshot, ListCounts, MainStatus, MergeMethod, PaneTab, PendingAction, PrList, RateLimit, RepoLocation, RunList, RunSnapshot, Snapshot, TabState } from '../src/model'
16import { fetchPullRequest, fetchPullRequestByNumber } from '../src/query'
17import { MAIN_STATUS_POLL_MS, fetchMainStatus, fetchRun, fetchRuns, lastRunPage, runAfter, runDelayMs, runPageSize, runProblemOf, runReference, runRowCount, runsAfter, runsProblemOf } from '../src/runs'
18import { createRefresher, etagsOf, mergeEtags, nextDelayMs } from '../src/scheduler'
19import type { RefreshReason, RefreshTarget, RefresherDeps, SchedulerConfig } from '../src/scheduler'
20import { deriveStatus } from '../src/status'
21import { renderBand } from '../src/ui/band'
22import { confirmTitle } from '../src/ui/confirm'
23import { paletteFor } from '../src/ui/colors'
24import type { Palette } from '../src/ui/colors'
25import { count } from '../src/ui/format'
26import { renderIssueDetail } from '../src/ui/issue-detail'
27import { renderIssues } from '../src/ui/issues'
28import { renderList } from '../src/ui/list'
29import { paneRowCount, renderPane } from '../src/ui/pane'
30import { renderRunDetail } from '../src/ui/run-detail'
31import { renderRuns } from '../src/ui/runs'
32import { renderTabs } from '../src/ui/tabs'
33import type { Handlers, IssueDetailView, IssuesView, ListHandlers, ListView, RepoHandlers, RunDetailView, RunsView, TabsView, UiTable, View } from '../src/ui/contract'
34
35const PANE_ID = 'gh-status'
36const PANE_TITLE = 'PR'
37/** Width the opening row count is estimated at; button wrapping is the only thing it changes. */
38const PANE_ROWS_COLUMNS = 80
39
40/** The tab row every view of the pane draws first. */
41const TAB_ROW = 1
42
43/**
44 * Per tab, the page size the last render drew with (`shown`) and the size the stored page index
45 * counts in (`basis`). Render hooks cannot write state, so a resize leaves the stored page alone
46 * and the render rebases it onto `shown`; the next/previous handlers read both and write the
47 * page back in `shown` pages. Not persisted: after a reload both start at PAGE_SIZE.
48 */
49const pageFit: Record<'prs' | 'issues' | 'actions', { shown: number; basis: number }> = {
50 prs: { shown: PAGE_SIZE, basis: PAGE_SIZE },
51 issues: { shown: PAGE_SIZE, basis: PAGE_SIZE },
52 actions: { shown: PAGE_SIZE, basis: PAGE_SIZE },
53}
54
55/** The page size the pane's room allows for the page that starts where the stored page does, and the page index in it. */
56function fitTab(tab: 'prs' | 'issues' | 'actions', storedPage: number, bodyRows: number | undefined, sizeFor: (firstIndex: number, rows: number | undefined) => number): { pageSize: number; page: number } {
57 const fit = pageFit[tab]
58 const rows = bodyRows !== undefined && bodyRows > 0 ? Math.max(1, bodyRows - TAB_ROW) : undefined
59 const pageSize = sizeFor(Math.max(0, storedPage) * fit.basis, rows)
60 fit.shown = pageSize
61 return { pageSize, page: rebasePage(storedPage, fit.basis, pageSize) }
62}
63
64/** The page `step` away from the stored one, in pages of the size last shown, clamped to 0..last; records the new basis. */
65function stepPage(tab: 'prs' | 'issues' | 'actions', storedPage: number, step: 1 | -1, lastFor: (pageSize: number) => number): number {
66 const fit = pageFit[tab]
67 const size = fit.shown
68 const next = Math.min(Math.max(rebasePage(storedPage, fit.basis, size) + step, 0), lastFor(size))
69 fit.basis = size
70 return next
71}
72
73/** Rows asked for an issue's or run's detail: the body is long, the engine clamps and the person can drag. */
74const ISSUE_DETAIL_ROWS = 30
75const RUN_DETAIL_ROWS = 24
76
77const DEFAULT_FAST_SECONDS = 5
78const DEFAULT_IDLE_SECONDS = 30
79
80/** Commands after which the pull request is likely to have changed. */
81const CHANGING_COMMAND = /\bgit\s+(push|pull|checkout|switch|rebase|merge|commit|reset)\b|\bgh\s+(pr|run)\b/
82
83const DEFAULT_COMMAND = 'pr'
84/** The name that is always registered, so the mod stays reachable when the configured one is taken. */
85const FALLBACK_COMMAND = 'gh-pr'
86/** Registered as well when free: one command for the PRs, Issues and Actions tabs. */
87const EXTRA_COMMAND = 'gh'
88const COMMAND_NAME = /^[a-z0-9-]{1,32}$/
89const PLUGIN_NAME = 'gh-status'
90
91const ARGUMENT_HINT = '<number> | prs | issues (i) | actions (ac) | issue <n> | run <id> | list (or ls) | back | refresh | open | close | copy | fix | rerun | merge | queue | dequeue | auto | admin | approve | changes | ready (or rr | m | q | dq | a | x)'
92
93const usageFor = (name: string): string => `Usage: /${name} [<number> (a pull request or an issue) | prs | issues (i) | actions (ac) | issue <n> | run <id> | list (ls) | back (up one level) | refresh | open | close | copy | fix | rerun (rr) | merge (m) | queue (q) | dequeue (dq) | auto (a) | admin (x) | approve | changes | ready]`
94
95/** The configured command name, or 'pr' when it is not 1 to 32 characters of a-z, 0-9 and '-'. */
96function commandNameOf(value: unknown): string {
97 return typeof value === 'string' && COMMAND_NAME.test(value) ? value : DEFAULT_COMMAND
98}
99
100/** The write subcommands and their short aliases. */
101const WRITE_COMMANDS: Record<string, 'merge' | 'enqueue' | 'dequeue' | 'auto' | 'admin-merge' | 'approve' | 'request-changes' | 'ready-for-review'> = {
102 merge: 'merge',
103 m: 'merge',
104 queue: 'enqueue',
105 q: 'enqueue',
106 dequeue: 'dequeue',
107 dq: 'dequeue',
108 auto: 'auto',
109 a: 'auto',
110 admin: 'admin-merge',
111 x: 'admin-merge',
112 approve: 'approve',
113 changes: 'request-changes',
114 ready: 'ready-for-review',
115}
116
117/** How long after the mod's own write its matching transition toast is still dropped. */
118const OWN_TOAST_MS = 120_000
119
120const snapshotState = atom({ plugin: 'gh-status', key: 'snapshot' } as const, { kind: 'loading', at: '' })
121const jobsState = atom({ plugin: 'gh-status', key: 'jobs' } as const, [])
122const rateLimitState = atom({ plugin: 'gh-status', key: 'rateLimit' } as const, null)
123const lastSeenState = atom({ plugin: 'gh-status', key: 'lastSeen' } as const, {})
124const etagsState = atom({ plugin: 'gh-status', key: 'etags' } as const, {})
125const pendingState = atom({ plugin: 'gh-status', key: 'pending' } as const, null)
126const outcomeState = atom({ plugin: 'gh-status', key: 'outcome' } as const, null)
127const selectedState = atom({ plugin: 'gh-status', key: 'selected' } as const, null)
128const selectedSnapshotState = atom({ plugin: 'gh-status', key: 'selectedSnapshot' } as const, null)
129const listState = atom({ plugin: 'gh-status', key: 'list' } as const, null)
130const listPageState = atom({ plugin: 'gh-status', key: 'listPage' } as const, 0)
131const listHideDraftsState = atom({ plugin: 'gh-status', key: 'listHideDrafts' } as const, false)
132const countsState = atom({ plugin: 'gh-status', key: 'counts' } as const, null)
133const paneViewState = atom({ plugin: 'gh-status', key: 'paneView' } as const, 'detail')
134const paneFocusState = atom({ plugin: 'gh-status', key: 'paneFocus' } as const, null)
135const paneRingState = atom({ plugin: 'gh-status', key: 'paneRing' } as const, null)
136const FRESH_TAB: TabState = { level: 'list', page: 0, focus: null, selected: null }
137const paneTabState = atom({ plugin: 'gh-status', key: 'paneTab' } as const, 'prs')
138const issuesTabState = atom({ plugin: 'gh-status', key: 'issuesTab' } as const, FRESH_TAB)
139const actionsTabState = atom({ plugin: 'gh-status', key: 'actionsTab' } as const, FRESH_TAB)
140const issuesState = atom({ plugin: 'gh-status', key: 'issues' } as const, null)
141const issueSnapshotState = atom({ plugin: 'gh-status', key: 'issueSnapshot' } as const, null)
142const runsState = atom({ plugin: 'gh-status', key: 'runs' } as const, null)
143const runSnapshotState = atom({ plugin: 'gh-status', key: 'runSnapshot' } as const, null)
144const mainStatusState = atom({ plugin: 'gh-status', key: 'mainStatus' } as const, null)
145
146/**
147 * The timers of the running session, at module scope: a second session.start (hot reload,
148 * resume) cancels the first one's before it builds its own, so timers never multiply.
149 * `generation` lets the flights of an older start notice they must not re-arm anything.
150 */
151/** Options the render hooks read; set by register. */
152/** The docked pane's default share of the terminal width, in percent (option `paneWidthPercent`); 0 leaves it to Claude Code. */
153const DEFAULT_PANE_WIDTH_PERCENT = 35
154/** The narrowest docked pane the share asks for, in cells. */
155const DOCK_MIN_COLUMNS = 60
156
157const settings = { isMainShown: true, paneWidthPercent: DEFAULT_PANE_WIDTH_PERCENT }
158
159/** The terminal's width in cells as last seen (a command's presentation or a drawing's viewport); null until known. */
160let terminalColumns: number | null = null
161
162/** What `$.ui.open` asks for the docked pane: a share of the terminal, never under DOCK_MIN_COLUMNS; nothing when off or unknown. */
163function dockColumns(): { columns?: number } {
164 if (settings.paneWidthPercent <= 0 || terminalColumns === null) return {}
165 return { columns: Math.max(DOCK_MIN_COLUMNS, Math.round((terminalColumns * settings.paneWidthPercent) / 100)) }
166}
167
168const timers: { branch: Timer | null; list: Timer | null; counts: Timer | null; tab: Timer | null; main: Timer | null } = { branch: null, list: null, counts: null, tab: null, main: null }
169let generation = 0
170
171/** The repository (lowercase `owner/repo`) the Issues and Actions data was read for; a change of repository resets them. */
172let tabsRepo: string | null = null
173
174/** The etag of the last main-branch read and the repository it belongs to. */
175const mainCache: { repo: string | null; etag: string | null } = { repo: null, etag: null }
176
177/** What the PR views call to stop the Issues and Actions polling; set by session.start. */
178const controls = { stopTab: (): void => {} }
179
180function cancelTimers(): void {
181 for (const key of ['branch', 'list', 'counts', 'tab', 'main'] as const) {
182 timers[key]?.cancel()
183 timers[key] = null
184 }
185}
186
187/** One confirm press at a time: a second press while one is being handled is dropped. */
188const confirmGuard = { isBusy: false }
189
190/** One re-run at a time: a second press while one is running is dropped. */
191const rerunGuard = { isBusy: false }
192
193/** The transition toast the mod's own successful write makes redundant; dropped once it shows up. */
194const ownToast: { prefix: string | null; untilMs: number } = { prefix: null, untilMs: 0 }
195
196/** What session.start builds and the other hooks use; null until then. */
197type Live = {
198 /** Refreshes the branch's pull request and the picked one, then re-arms the timer for the interval the new state asks for. */
199 run(reason: RefreshReason): Promise<void>
200 /** Reads the repository's open pull requests now (single flight) and, while the pane shows the list, keeps reading them. */
201 runList(): Promise<void>
202 /** Stops the list polling; the pane closed or shows a pull request. */
203 stopList(): void
204 /** The directory changed: refreshes the branch's pull request, the counts and, while the pane shows the list, the list. */
205 cwdChanged(): Promise<void>
206 /** Polls what the pane shows now: the Issues or Actions list or detail (read at once), nothing for the PRs tab. */
207 syncTab(): Promise<void>
208 /** Reads what the Issues or Actions tab shows now, outside its rhythm. */
209 refreshTab(): Promise<void>
210 /** Whether a number is a pull request or an issue of the repository; GitHub numbers both together. */
211 kindOfNumber(number: number): Promise<'pr' | 'issue'>
212}
213
214/** Which pull request a handler acts on: the branch's (the band) or the one the pane shows (the picked one, else the branch's). */
215type Subject = 'branch' | 'pane'
216
217type PrSnapshot = Extract<Snapshot, { kind: 'pr' }>
218
219function numberOption(value: unknown, fallback: number): number {
220 return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
221}
222
223function configOf(options: PluginOptions): SchedulerConfig {
224 return {
225 fastIntervalSeconds: numberOption(options.fastIntervalSeconds, DEFAULT_FAST_SECONDS),
226 idleIntervalSeconds: numberOption(options.idleIntervalSeconds, DEFAULT_IDLE_SECONDS),
227 toasts: options.toasts !== false,
228 }
229}
230
231function seenKey(snapshot: Extract<Snapshot, { kind: 'pr' }>): string {
232 return `${snapshot.location.owner ?? ''}/${snapshot.location.repo ?? ''}#${snapshot.pr.number}`
233}
234
235type Pick = { number: number; repo: string }
236
237/** The lowercase `owner/repo` a snapshot belongs to, or null when it has no repository. */
238function snapshotSlug(snapshot: Snapshot): string | null {
239 return 'location' in snapshot && snapshot.location !== null ? (repoSlug(snapshot.location)?.toLowerCase() ?? null) : null
240}
241
242/** True when the snapshot is the pull request the pick names: same number, same repository. */
243function isPick(selected: Pick, snapshot: PrSnapshot): boolean {
244 return snapshot.pr.number === selected.number && snapshotSlug(snapshot) === selected.repo
245}
246
247/**
248 * The snapshot a subject shows. The band always shows the branch's. The pane shows
249 * the picked pull request when one is set: its own snapshot, the branch's when the
250 * pick is the branch's pull request, else a loading line until the first answer.
251 */
252async function subjectSnapshot($: EngineInterface, subject: Subject): Promise<{ snapshot: Snapshot; isSelected: boolean }> {
253 const branch = await read($, snapshotState)
254 const selected = subject === 'pane' ? await read($, selectedState) : null
255 if (selected === null) return { snapshot: branch, isSelected: false }
256 const own = await read($, selectedSnapshotState)
257 if (own !== null && (own.kind !== 'pr' || isPick(selected, own))) return { snapshot: own, isSelected: true }
258 if (branch.kind === 'pr' && isPick(selected, branch)) return { snapshot: branch, isSelected: true }
259 return { snapshot: { kind: 'loading', at: '' }, isSelected: true }
260}
261
262/** The pull request a pending write is bound to, found among the pick and the branch's; null when neither is it any more. */
263async function pendingTarget($: EngineInterface, pending: PendingAction): Promise<PrSnapshot | null> {
264 const own = await read($, selectedSnapshotState)
265 for (const candidate of [own, await read($, snapshotState)]) {
266 if (candidate !== null && candidate.kind === 'pr' && candidate.pr.number === pending.prNumber && repoSlug(candidate.location)?.toLowerCase() === pending.repo) return candidate
267 }
268 return null
269}
270
271async function readView($: EngineInterface, columns: number, surface: 'terminal' | 'desktop', palette: Palette, subject: Subject): Promise<View> {
272 const { snapshot, isSelected } = await subjectSnapshot($, subject)
273 const jobs = await read($, jobsState)
274 const lastSeen = await read($, lastSeenState)
275 const outcome = await read($, outcomeState)
276 const stored = await read($, pendingState)
277 const nowMs = await $.clock.now()
278 const status = snapshot.kind === 'pr' ? deriveStatus(snapshot.pr, snapshot.account.login, lastSeen[seenKey(snapshot)] ?? null) : null
279 // With a pick set, a confirmation shows only where its pull request is shown.
280 const isElsewhere = (await read($, selectedState)) !== null && stored !== null && !(snapshot.kind === 'pr' && snapshot.pr.number === stored.prNumber)
281 const counts = await read($, countsState)
282 const mainStatus = settings.isMainShown ? await read($, mainStatusState) : null
283 return { snapshot, status, isSelected, jobs, nowMs, columns, surface, palette, outcome, pending: isElsewhere ? null : stored, hasPending: stored !== null, counts, mainStatus }
284}
285
286async function readListView($: EngineInterface, columns: number, surface: 'terminal' | 'desktop', palette: Palette, bodyRows?: number): Promise<ListView> {
287 const list = await read($, listState)
288 const hideDrafts = await read($, listHideDraftsState)
289 const fit = fitTab('prs', await read($, listPageState), bodyRows, (first, rows) => listPageSize(list, first, hideDrafts, rows))
290 return {
291 list,
292 page: fit.page,
293 pageSize: fit.pageSize,
294 hideDrafts,
295 counts: await read($, countsState),
296 nowMs: await $.clock.now(),
297 columns,
298 surface,
299 palette,
300 }
301}
302
303/** The tab row's data: the counts behind each name, the worst main-branch state, locked while a confirmation waits. */
304async function readTabsView($: EngineInterface, palette: Palette): Promise<TabsView> {
305 const counts = await read($, countsState)
306 const list = await read($, listState)
307 const main = settings.isMainShown ? await read($, mainStatusState) : null
308 return {
309 tab: await read($, paneTabState),
310 prCount: counts !== null ? counts.open : list !== null && list.kind === 'list' ? list.total : null,
311 issueCount: counts?.issues ?? null,
312 mainWorst: main === null ? null : main.worst,
313 isLocked: (await read($, pendingState)) !== null,
314 palette,
315 }
316}
317
318/** The number an issue snapshot is about. */
319const issueNumberOf = (snapshot: IssueSnapshot): number => (snapshot.kind === 'issue' ? snapshot.issue.number : snapshot.number)
320
321/** The id of the run a run snapshot is about. */
322const runIdOf = (snapshot: RunSnapshot): number => (snapshot.kind === 'run' ? snapshot.run.id : snapshot.runId)
323
324/** What the active tab draws below the tab row. */
325async function readTabBody($: EngineInterface, table: UiTable, columns: number, surface: 'terminal' | 'desktop', palette: Palette, live: Live | null, bodyRows?: number): Promise<RenderElement> {
326 const tab = await read($, paneTabState)
327 const nowMs = await $.clock.now()
328 if (tab === 'prs') {
329 if ((await read($, paneViewState)) === 'list') {
330 const listView = await readListView($, columns, surface, palette, bodyRows)
331 return renderList(table, listView, listHandlersFor($, live))
332 }
333 const view = await readView($, columns, surface, palette, 'pane')
334 return renderPane(table, view, handlersFor($, live, surface, 'pane'))
335 }
336 const handlers = repoHandlersFor($, live, surface)
337 if (tab === 'issues') {
338 const state = await read($, issuesTabState)
339 if (state.level === 'list' || state.selected === null) {
340 const issues = await read($, issuesState)
341 const branch = await read($, snapshotState)
342 const viewer = issues !== null && issues.kind === 'list' ? issues.account.login : 'account' in branch ? branch.account.login : ''
343 const fit = fitTab('issues', state.page, bodyRows, (first, rows) => issuePageSize(issues, first, rows))
344 const view: IssuesView = { issues, page: fit.page, pageSize: fit.pageSize, viewer, nowMs, columns, surface, palette }
345 return renderIssues(table, view, handlers)
346 }
347 const stored = await read($, issueSnapshotState)
348 const snapshot: IssueSnapshot = stored !== null && issueNumberOf(stored) === state.selected ? stored : { kind: 'loading', at: '', number: state.selected }
349 const view: IssueDetailView = { snapshot, nowMs, columns, surface, palette }
350 return renderIssueDetail(table, view, handlers)
351 }
352 const state = await read($, actionsTabState)
353 if (state.level === 'list' || state.selected === null) {
354 const runs = await read($, runsState)
355 const fit = fitTab('actions', state.page, bodyRows, (first, rows) => runPageSize(runs, first, rows))
356 const view: RunsView = { runs, page: fit.page, pageSize: fit.pageSize, nowMs, columns, surface, palette }
357 return renderRuns(table, view, handlers)
358 }
359 const stored = await read($, runSnapshotState)
360 const snapshot: RunSnapshot = stored !== null && runIdOf(stored) === state.selected ? stored : { kind: 'loading', at: '', runId: state.selected }
361 const view: RunDetailView = { snapshot, outcome: null, nowMs, columns, surface, palette }
362 return renderRunDetail(table, view, handlers)
363}
364
365async function openUrl($: EngineInterface, url: string): Promise<void> {
366 if (!/^https:\/\//.test(url)) return
367 for (const opener of ['open', 'xdg-open']) {
368 try {
369 const ran = await $.process.run([opener, url], { timeoutMs: 10_000 })
370 if (ran.exitCode === 0) return
371 } catch {
372 // try the next opener
373 }
374 }
375}
376
377/** Opens the pane on its detail view, with the keyboard when asked (granted from an empty prompt). */
378async function showPane($: EngineInterface, isFocused: boolean): Promise<UiOpenResult> {
379 controls.stopTab()
380 await update($, paneTabState, () => 'prs')
381 await update($, paneViewState, () => 'detail')
382 const { snapshot } = await subjectSnapshot($, 'pane')
383 // Docked, the pane takes the height of the screen; inline it would get a third and cut off,
384 // so ask for what the content needs. A size the person dragged still wins (engine).
385 const view = await readView($, PANE_ROWS_COLUMNS, 'terminal', paletteFor(undefined), 'pane')
386 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE, rows: TAB_ROW + paneRowCount(view, { openRun: () => undefined }), ...dockColumns(), ...(isFocused ? { focus: true } : {}) })
387 if (snapshot.kind === 'pr') {
388 const nowMs = await $.clock.now()
389 await update($, lastSeenState, seen => ({ ...seen, [seenKey(snapshot)]: new Date(nowMs).toISOString() }))
390 }
391 return opened
392}
393
394async function openPane($: EngineInterface, live: Live | null): Promise<string> {
395 // With the keyboard, so 1, 2, 3 and the row keys work at once; Esc gives it back to the prompt.
396 const opened = await showPane($, true)
397 if (live !== null) void live.run('command')
398 return opened.isPlaced ? 'PR pane opened.' : `PR pane opened but not drawn yet: ${opened.reason}`
399}
400
401async function closePane($: EngineInterface): Promise<void> {
402 await $.ui.close({ id: PANE_ID })
403}
404
405async function openOnGitHub($: EngineInterface, subject: Subject): Promise<boolean> {
406 const { snapshot } = await subjectSnapshot($, subject)
407 if (snapshot.kind !== 'pr') return false
408 const exec: Exec = (argv, init) => $.process.run(argv, init)
409 const { pr, location, account } = snapshot
410 const ran = await runGh(exec, ['pr', 'view', String(pr.number), '--web', '-R', `${location.owner ?? ''}/${location.repo ?? ''}`], { login: account.login, cwd: location.cwd })
411 if (!ran.isOk) await openUrl($, pr.url)
412 // The browser may be signed in to another github.com account, which gets a 404 on a private repository.
413 if (pr.isPrivate && account.source !== 'active') await $.ui.toast(`gh-status: if GitHub shows 404, sign in to github.com as @${account.login}`)
414 return true
415}
416
417async function copyUrl($: EngineInterface, surface: RenderSurface | undefined, subject: Subject): Promise<string | null> {
418 const { snapshot } = await subjectSnapshot($, subject)
419 if (snapshot.kind !== 'pr') return null
420 await $.ui.copy({ text: snapshot.pr.url, surface })
421 return snapshot.pr.url
422}
423
424/**
425 * Asks for a write action: sets `pending`, which the band and the pane's Merge
426 * section then show as a confirmation. The write happens in confirmPending, after
427 * the person's confirm press. A slash command also opens the pane with the keyboard
428 * (`isFromCommand`); a button press opens nothing, the person is already there.
429 */
430async function requestAction($: EngineInterface, kind: ActionKind, subject: Subject, isFromCommand = false): Promise<string> {
431 // A confirm is being handled: a new request must not replace what it acts on.
432 if (confirmGuard.isBusy) return 'A confirmation is in progress, try again in a moment.'
433 const { snapshot } = await subjectSnapshot($, subject)
434 if (snapshot.kind !== 'pr') return 'No pull request for this branch.'
435 const openedAt = await $.clock.now()
436 const pending = buildPending(kind, snapshot, undefined, openedAt)
437 if (pending === null) {
438 const offered = availableActions(snapshot.pr, snapshot.account.login)
439 return `That is not available for PR #${snapshot.pr.number} right now. Available: ${offered.length === 0 ? 'nothing' : offered.join(', ')}.`
440 }
441 await update($, outcomeState, () => null)
442 await update($, pendingState, () => pending)
443 // Warm the account token now, so the write does not wait on a process spawn after the press.
444 void tokenFor((argv, init) => $.process.run(argv, init), pending.login)
445 if (!isFromCommand) return `Confirm: ${confirmTitle(pending)}.`
446 const opened = await showPane($, true)
447 if (!opened.isPlaced) return `Confirm in the band, the pane is not drawn yet (${opened.reason}): ${confirmTitle(pending)}.`
448 // focus is a request: a band the person holds refuses it, and then y reaches the band, which asks the same.
449 const isFocused = (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isFocused)
450 if (!isFocused) await $.ui.toast('gh-status: press y in the band, or click the pane to focus it')
451 return `Confirm in the pane: ${confirmTitle(pending)}.`
452}
453
454/** Auto-merge is one toggle: off when it is on, on when it is off. */
455async function requestAutoMerge($: EngineInterface, subject: Subject, isFromCommand = false): Promise<string> {
456 const { snapshot } = await subjectSnapshot($, subject)
457 if (snapshot.kind !== 'pr') return 'No pull request for this branch.'
458 return requestAction($, snapshot.pr.autoMerge === null ? 'auto-merge-on' : 'auto-merge-off', subject, isFromCommand)
459}
460
461async function cancelPending($: EngineInterface): Promise<void> {
462 await update($, pendingState, () => null)
463}
464
465/**
466 * The review field changed: the text lives only in the pending review's body, written
467 * in one update, so the field, the confirmation and the press all read the same value.
468 * Enter in the field comes here as well and posts nothing; only confirmPending writes.
469 */
470async function setReviewBody($: EngineInterface, text: string): Promise<void> {
471 const body = clampReviewText(text)
472 await update($, pendingState, (current: PendingAction | null) =>
473 current !== null && (current.kind === 'approve' || current.kind === 'request-changes') ? { ...current, body } : current,
474 )
475}
476
477async function selectMethod($: EngineInterface, method: MergeMethod): Promise<void> {
478 const pending = await read($, pendingState)
479 if (pending === null) return
480 const target = await pendingTarget($, pending)
481 if (target === null) return
482 const allowed = target.pr.allowedMethods
483 await update($, pendingState, (current: PendingAction | null) => (current === null ? null : withMethod(current, method, allowed)))
484}
485
486/**
487 * The confirm press, the only path to a write. `isSecondStep` is the admin step
488 * the pressed button belonged to; a press from a stale drawing is ignored, and
489 * so is one right after the confirmation (or its second step) was asked for. The write acts on
490 * a fresh full fetch, never on the stored snapshot.
491 */
492async function confirmPending($: EngineInterface, live: Live | null, isSecondStep: boolean, drawnBody?: string): Promise<void> {
493 if (confirmGuard.isBusy) return
494 confirmGuard.isBusy = true
495 try {
496 const pending = await read($, pendingState)
497 if (pending === null) return
498 const step = confirmStep(pending, isSecondStep)
499 if (step === 'ignore') return
500 const pressedAt = await $.clock.now()
501 if (isTooEarly(pending, pressedAt)) return
502 if ((pending.kind === 'approve' || pending.kind === 'request-changes') && pending.body !== drawnBody) {
503 // The press belongs to a drawing with other text than the live one: nothing is posted, the person looks again.
504 await update($, outcomeState, () => ({ kind: pending.kind, isOk: false, message: TEXT_CHANGED, at: new Date(pressedAt).toISOString() }))
505 return
506 }
507 if (isReviewTextMissing(pending)) {
508 // Refused before any call: the confirmation stays up with the reason.
509 await update($, outcomeState, () => ({ kind: pending?.kind ?? 'request-changes', isOk: false, message: CHANGES_NEED_TEXT, at: new Date(pressedAt).toISOString() }))
510 return
511 }
512 if (step === 'advance') {
513 await update($, pendingState, (current: PendingAction | null) =>
514 current !== null && current.kind === 'admin-merge' && !current.isSecondStep ? { ...current, isSecondStep: true, openedAt: pressedAt } : current,
515 )
516 return
517 }
518 const exec: Exec = (argv, init) => $.process.run(argv, init)
519 // The pull request the confirmation is bound to: the pick's or the branch's, never another one.
520 const stored = await pendingTarget($, pending)
521 const branch = await read($, snapshotState)
522 const isBranchPr = branch.kind === 'pr' && isPick({ number: pending.prNumber, repo: pending.repo }, branch)
523 const fail = async (message: string): Promise<void> => {
524 await update($, outcomeState, () => ({ kind: pending.kind, isOk: false, message, at: new Date(pressedAt).toISOString() }))
525 }
526 if (stored === null) return await fail('no pull request to act on, review again')
527 // The stored snapshot may be seconds old: re-read the pull request in full right before the write.
528 // The branch's own pull request is looked up by its branch, any other one by its number.
529 const fetched = isBranchPr ? await fetchPullRequest(exec, stored.location, pending.login) : await fetchPullRequestByNumber(exec, stored.location, pending.login, pending.prNumber)
530 if (fetched.kind !== 'ok' || fetched.pr === null) return await fail(RECHECK_FAILED)
531 const freshPr = fetched.pr
532 const latest: Snapshot = { kind: 'pr', at: new Date(await $.clock.now()).toISOString(), location: stored.location, account: stored.account, pr: freshPr }
533 // The fresh picture goes to the slot it belongs to: the pick's when it is the picked pull request, the branch's when it is the branch's.
534 const picked = await read($, selectedState)
535 const isPicked = picked !== null && isPick(picked, latest)
536 if (isPicked) await update($, selectedSnapshotState, () => latest)
537 if (isBranchPr) await update($, snapshotState, () => latest)
538 // The confirmation may have been cancelled or changed while we fetched: act on what is pending now, or not at all.
539 const isSame = (current: PendingAction | null): boolean => current !== null && JSON.stringify(current) === JSON.stringify(pending)
540 if (!isSame(await read($, pendingState))) return
541 let wasCancelled = false
542 const isStillPending = async (): Promise<boolean> => {
543 const isStill = isSame(await read($, pendingState))
544 if (!isStill) wasCancelled = true
545 return isStill
546 }
547 const outcome = await execute(exec, pending, latest, new Date(await $.clock.now()).toISOString(), isStillPending)
548 if (wasCancelled) return
549 await update($, outcomeState, () => outcome)
550 if (!outcome.isOk) {
551 if (outcome.message === RULES_CHANGED && pending.kind === 'admin-merge') {
552 // Show the rules as they are now, back at step 1.
553 await update($, pendingState, (current: PendingAction | null) =>
554 isSame(current) ? { ...pending, bypassed: bypassedRules(freshPr), isSecondStep: false, openedAt: pressedAt } : current,
555 )
556 }
557 if (outcome.isUnknown !== true) return
558 // Timeout or unknown failure: the write may have gone through. Do not leave the confirmation armed.
559 await update($, pendingState, () => null)
560 await $.ui.toast(`PR #${pending.prNumber}: result unknown, refreshed status`)
561 if (live !== null) void live.run('command')
562 return
563 }
564 await update($, pendingState, () => null)
565 // The action's own toast says it; the transition toast of the next refresh would say it twice.
566 ownToast.prefix = transitionToastPrefix(pending.kind, pending.prNumber)
567 ownToast.untilMs = pressedAt + OWN_TOAST_MS
568 await $.ui.toast(outcome.message)
569 if (live !== null) void live.run('command')
570 } finally {
571 confirmGuard.isBusy = false
572 }
573}
574
575/** The lowercase `owner/repo` of the repository the session's directory is in; null when it is in none. */
576async function currentRepo($: EngineInterface): Promise<string | null> {
577 const location = await readLocation((argv, init) => $.process.run(argv, init), await $.session.cwd())
578 return location === null ? null : (repoSlug(location)?.toLowerCase() ?? null)
579}
580
581/**
582 * After a change of repository the pick, its snapshot and any pending write belong to the one
583 * before: all three are dropped. `slug` is the lowercase `owner/repo` now, or null when there is none.
584 */
585async function dropIfRepoChanged($: EngineInterface, slug: string | null): Promise<void> {
586 if (tabsRepo !== null && tabsRepo !== (slug ?? '')) {
587 tabsRepo = null
588 mainCache.repo = null
589 mainCache.etag = null
590 await update($, issuesTabState, () => FRESH_TAB)
591 await update($, actionsTabState, () => FRESH_TAB)
592 await update($, issuesState, () => null)
593 await update($, issueSnapshotState, () => null)
594 await update($, runsState, () => null)
595 await update($, runSnapshotState, () => null)
596 await update($, mainStatusState, () => null)
597 }
598 const selected = await read($, selectedState)
599 const pending = await read($, pendingState)
600 const isChanged = (repo: string): boolean => repo !== (slug ?? '')
601 if (!(selected !== null && isChanged(selected.repo)) && !(pending !== null && isChanged(pending.repo))) return
602 await update($, selectedState, () => null)
603 await update($, selectedSnapshotState, () => null)
604 await update($, pendingState, () => null)
605 await update($, outcomeState, () => null)
606}
607
608/**
609 * Shows another open pull request of the repository in the pane: sets the pick, drops
610 * the confirmation and result of the one before, switches the same pane to its detail
611 * view and refreshes. The band keeps showing the branch's pull request.
612 */
613async function selectPr($: EngineInterface, live: Live | null, number: number): Promise<string> {
614 if (!Number.isSafeInteger(number) || number < 1) return `${number} is not a pull request number.`
615 if (confirmGuard.isBusy) return 'A confirmation is in progress, try again in a moment.'
616 // The pick belongs to the repository it was made in: a later change of repository drops it.
617 const repo = await currentRepo($)
618 if (repo === null) return 'No GitHub repository here.'
619 await update($, selectedState, () => ({ number, repo }))
620 await update($, selectedSnapshotState, () => null)
621 await update($, pendingState, () => null)
622 await update($, outcomeState, () => null)
623 // The list is not on screen any more: it stops being read.
624 if (live !== null) live.stopList()
625 const opened = await showPane($, true)
626 if (live !== null) void live.run('command')
627 return opened.isPlaced ? `Showing PR #${number} in the pane.` : `PR #${number} selected but the pane is not drawn yet: ${opened.reason}`
628}
629
630/** Drops the pick with its snapshot, confirmation and result; false when nothing was picked. */
631async function leaveSelection($: EngineInterface): Promise<boolean> {
632 const selected = await read($, selectedState)
633 if (selected === null) return false
634 await update($, selectedState, () => null)
635 await update($, selectedSnapshotState, () => null)
636 await update($, pendingState, () => null)
637 await update($, outcomeState, () => null)
638 return true
639}
640
641/**
642 * Leaves the picked pull request: the same pane shows the list again, the ring back on
643 * the row it was opened from. The old list data stays visible until the fresh one arrives.
644 */
645async function back($: EngineInterface, live: Live | null): Promise<void> {
646 if (confirmGuard.isBusy) return
647 const selected = await read($, selectedState)
648 await leaveSelection($)
649 const key = selected === null ? null : `pr-${selected.number}`
650 if (key !== null) {
651 await update($, paneFocusState, () => key)
652 await update($, paneRingState, () => key)
653 }
654 await showList($, live, false)
655 if (key === null) return
656 void focusDrawn($, key)
657}
658
659/** Switches the pane to its list view, with the keyboard when asked, sized to its current page, and reads the list now. */
660async function showList($: EngineInterface, live: Live | null, isFocused: boolean): Promise<UiOpenResult> {
661 controls.stopTab()
662 await update($, paneTabState, () => 'prs')
663 await update($, paneViewState, () => 'list')
664 const rows = TAB_ROW + listRowCount(await read($, listState), await read($, listPageState), await read($, listHideDraftsState))
665 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE, rows, ...dockColumns(), ...(isFocused ? { focus: true } : {}) })
666 if (live !== null) void live.runList()
667 return opened
668}
669
670/** Shows the list of open pull requests in the pane, with the keyboard. */
671async function openList($: EngineInterface, live: Live | null): Promise<string> {
672 const opened = await showList($, live, true)
673 return opened.isPlaced ? 'Open PRs listed in the pane.' : `Open PRs listed but the pane is not drawn yet: ${opened.reason}`
674}
675
676async function setPage($: EngineInterface, step: 1 | -1): Promise<void> {
677 const list = await read($, listState)
678 const hideDrafts = await read($, listHideDraftsState)
679 const stored = await read($, listPageState)
680 await update($, listPageState, () => stepPage('prs', stored, step, size => lastPage(list, hideDrafts, size)))
681}
682
683async function toggleDrafts($: EngineInterface): Promise<void> {
684 await update($, listHideDraftsState, (isHidden: boolean) => !isHidden)
685 await update($, listPageState, () => 0)
686}
687
688/** The handler closures of the list view. They read and open; none of them writes to GitHub. */
689function listHandlersFor($: EngineInterface, live: Live | null): ListHandlers {
690 return {
691 selectPr: number => void selectPr($, live, number),
692 nextPage: () => void setPage($, 1),
693 prevPage: () => void setPage($, -1),
694 toggleDrafts: () => void toggleDrafts($),
695 refresh: () => {
696 if (live !== null) void live.runList()
697 },
698 closePane: () => void closePane($),
699 openUrl: url => void openUrl($, url),
700 }
701}
702
703/** Whether a confirmation waits: then no tab switch or cross link may move away from it. */
704async function isConfirming($: EngineInterface): Promise<boolean> {
705 return (await read($, pendingState)) !== null
706}
707
708function readTabState($: EngineInterface, tab: 'issues' | 'actions'): Promise<TabState> {
709 return tab === 'issues' ? read($, issuesTabState) : read($, actionsTabState)
710}
711
712async function patchTabState($: EngineInterface, tab: 'issues' | 'actions', patch: (state: TabState) => TabState): Promise<void> {
713 if (tab === 'issues') await update($, issuesTabState, patch)
714 else await update($, actionsTabState, patch)
715}
716
717/** Rows the view of an Issues or Actions tab needs, without the tab row. */
718async function tabViewRows($: EngineInterface, tab: 'issues' | 'actions'): Promise<number> {
719 const state = await readTabState($, tab)
720 if (tab === 'issues') return state.level === 'list' ? issueRowCount(await read($, issuesState), state.page) : ISSUE_DETAIL_ROWS
721 return state.level === 'list' ? runRowCount(await read($, runsState), state.page) : RUN_DETAIL_ROWS
722}
723
724/**
725 * Puts the focus ring where the tab was left: the remembered row in a list, the header
726 * title in a detail (the key the list row shares, `pr-<n>` for a picked pull request, the
727 * branch pull request's `pr-anchor`). A request: where the engine refuses it (no keyboard, or `claude plugin
728 * test`) the ring stays where it is.
729 */
730/**
731 * Puts the ring on `key` once the new view is drawn. A list row and the detail header
732 * share their key, so a move asked for before the redraw lands on the header that is
733 * about to go, and the ring then falls to the first row. A short wait lets the view
734 * that changed draw first.
735 */
736const FOCUS_SETTLE_MS = 80
737
738async function focusDrawn($: EngineInterface, key: string): Promise<void> {
739 try {
740 await $.clock.sleep(FOCUS_SETTLE_MS)
741 await $.ui.focus({ requestId: PANE_ID, key })
742 } catch {
743 // the ring stays where the engine put it
744 }
745}
746
747async function restoreFocus($: EngineInterface, tab: PaneTab): Promise<void> {
748 let key: string | null
749 if (tab === 'prs') {
750 const focus = await read($, paneFocusState)
751 const picked = await read($, selectedState)
752 if ((await read($, paneViewState)) === 'list') key = focus !== null && focus.startsWith('pr-') ? focus : null
753 else key = picked === null ? 'pr-anchor' : `pr-${picked.number}`
754 } else {
755 const state = await readTabState($, tab)
756 if (state.level === 'list') key = state.focus
757 else if (state.selected === null) key = null
758 else key = `${tab === 'issues' ? 'issue' : 'run'}-${state.selected}`
759 }
760 if (key === null) return
761 await update($, paneRingState, () => key)
762 void focusDrawn($, key)
763}
764
765/** Opens the pane on what the active tab shows, sized to it, and starts the poll that view needs. */
766async function showTabView($: EngineInterface, live: Live | null, isFocused: boolean): Promise<UiOpenResult> {
767 const tab = await read($, paneTabState)
768 if (tab === 'prs') {
769 if ((await read($, paneViewState)) === 'list') return await showList($, live, isFocused)
770 const opened = await showPane($, isFocused)
771 if (live !== null) void live.run('command')
772 return opened
773 }
774 if (live !== null) live.stopList()
775 const rows = TAB_ROW + (await tabViewRows($, tab))
776 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE, rows, ...dockColumns(), ...(isFocused ? { focus: true } : {}) })
777 if (live !== null) void live.syncTab()
778 return opened
779}
780
781/**
782 * Makes `tab` the active one, applies `patch` to its state, shows it and, when asked,
783 * puts the ring where that tab was left. Each tab keeps its own level, page and row.
784 */
785async function enterTab($: EngineInterface, live: Live | null, tab: PaneTab, patch: ((state: TabState) => TabState) | null, isFocused: boolean, isRestoringFocus: boolean): Promise<UiOpenResult> {
786 await update($, paneTabState, () => tab)
787 if (patch !== null && tab !== 'prs') await patchTabState($, tab, patch)
788 const opened = await showTabView($, live, isFocused)
789 if (isRestoringFocus) await restoreFocus($, tab)
790 return opened
791}
792
793/** The tab row's hotkeys 1, 2 and 3. Does nothing while a confirmation waits, so it never ends up in another tab. */
794async function showTab($: EngineInterface, live: Live | null, tab: PaneTab): Promise<void> {
795 if (await isConfirming($)) return
796 if ((await read($, paneTabState)) === tab) return
797 await enterTab($, live, tab, null, false, true)
798}
799
800const WAITING = 'A confirmation is waiting, answer it first.'
801
802/** Shows an issue in the Issues tab; the detail is read at once. */
803async function openIssue($: EngineInterface, live: Live | null, number: number, isFromCommand: boolean): Promise<string> {
804 if (!Number.isSafeInteger(number) || number < 1) return `${number} is not an issue number.`
805 if (await isConfirming($)) return WAITING
806 await update($, issueSnapshotState, () => null)
807 const opened = await enterTab($, live, 'issues', state => ({ ...state, level: 'detail', selected: number, focus: `issue-${number}` }), isFromCommand, !isFromCommand)
808 return opened.isPlaced ? `Showing issue #${number} in the pane.` : `Issue #${number} selected but the pane is not drawn yet: ${opened.reason}`
809}
810
811/** Shows a workflow run in the Actions tab; the detail is read at once. */
812async function openRun($: EngineInterface, live: Live | null, runId: number, isFromCommand: boolean): Promise<string> {
813 if (!Number.isSafeInteger(runId) || runId < 1) return `${runId} is not a run id.`
814 if (await isConfirming($)) return WAITING
815 await update($, runSnapshotState, () => null)
816 const opened = await enterTab($, live, 'actions', state => ({ ...state, level: 'detail', selected: runId, focus: `run-${runId}` }), isFromCommand, !isFromCommand)
817 return opened.isPlaced ? `Showing run ${runId} in the pane.` : `Run ${runId} selected but the pane is not drawn yet: ${opened.reason}`
818}
819
820/** The Issues or Actions tab at the level it was left; the first time the list. */
821async function openTabCommand($: EngineInterface, live: Live | null, tab: 'issues' | 'actions'): Promise<string> {
822 if (await isConfirming($)) return WAITING
823 const opened = await enterTab($, live, tab, null, true, true)
824 const name = tab === 'issues' ? 'Issues' : 'Actions'
825 return opened.isPlaced ? `${name} shown in the pane.` : `${name} shown but the pane is not drawn yet: ${opened.reason}`
826}
827
828/** Up one level in the active tab: detail to list, the ring back on the row it was opened from. */
829async function tabBack($: EngineInterface, live: Live | null): Promise<boolean> {
830 const tab = await read($, paneTabState)
831 if (tab === 'prs') return false
832 const state = await readTabState($, tab)
833 if (state.level !== 'detail') return false
834 const prefix = tab === 'issues' ? 'issue-' : 'run-'
835 const key = state.selected === null ? state.focus : `${prefix}${state.selected}`
836 await patchTabState($, tab, current => ({ ...current, level: 'list', selected: null, focus: key }))
837 await showTabView($, live, false)
838 await restoreFocus($, tab)
839 return true
840}
841
842async function pageTab($: EngineInterface, step: 1 | -1): Promise<void> {
843 const tab = await read($, paneTabState)
844 if (tab === 'prs') return
845 const issues = await read($, issuesState)
846 const runs = await read($, runsState)
847 const stored = (await readTabState($, tab)).page
848 const next = stepPage(tab, stored, step, size => (tab === 'issues' ? lastIssuePage(issues, size) : lastRunPage(runs, size)))
849 await patchTabState($, tab, state => ({ ...state, page: next, focus: null }))
850}
851
852/** Re-runs the failed jobs of the run the detail shows; no confirmation, like the pull request's `t`. */
853async function rerunRun($: EngineInterface, live: Live | null, runId: number): Promise<string> {
854 if (rerunGuard.isBusy) return 'A re-run is in progress, try again in a moment.'
855 rerunGuard.isBusy = true
856 try {
857 const snapshot = await read($, runSnapshotState)
858 if (snapshot === null || snapshot.kind !== 'run' || snapshot.run.id !== runId) return 'That run is not loaded.'
859 if (!canRerunRun(snapshot.run.state)) return 'Only a failed or timed-out run can be re-run.'
860 const exec: Exec = (argv, init) => $.process.run(argv, init)
861 const summary = await executeRerun(exec, snapshot.account.login, snapshot.location, [runId])
862 const label = `Run #${snapshot.run.runNumber} ${snapshot.run.name}`
863 const message = summary.ok.length > 0 ? `${label}: re-running failed jobs` : `${label}: could not re-run, ${summary.failed[0]?.message ?? 'gh failed'}`
864 await $.ui.toast(message)
865 if (summary.ok.length > 0 && live !== null) void live.refreshTab()
866 return message
867 } finally {
868 rerunGuard.isBusy = false
869 }
870}
871
872/** Puts a reference to the shown run's failed jobs into the prompt box. */
873async function runToClaude($: EngineInterface, runId: number): Promise<string> {
874 const snapshot = await read($, runSnapshotState)
875 if (snapshot === null || snapshot.kind !== 'run' || snapshot.run.id !== runId) return 'That run is not loaded.'
876 const text = runReference(snapshot)
877 if (text === null) return 'No failed jobs.'
878 const filled = await $.prompt.fill({ text, mode: 'insert' })
879 const message = filled.isFilled ? 'Failed run added to the prompt.' : 'The prompt could not take the failed run.'
880 await $.ui.toast(message)
881 return message
882}
883
884/** The handler closures of the tab row, the Issues and Actions views. None of them writes to GitHub but `rerunRun`, which re-runs CI. */
885function repoHandlersFor($: EngineInterface, live: Live | null, surface: RenderSurface | undefined): RepoHandlers {
886 return {
887 showTab: tab => void showTab($, live, tab),
888 selectIssue: number => void openIssue($, live, number, false),
889 selectRun: runId => void openRun($, live, runId, false),
890 openLinkedPr: number => {
891 void (async () => {
892 if (await isConfirming($)) return
893 await selectPr($, live, number)
894 })()
895 },
896 back: () => void tabBack($, live),
897 nextPage: () => void pageTab($, 1),
898 prevPage: () => void pageTab($, -1),
899 refresh: () => {
900 if (live !== null) void live.refreshTab()
901 },
902 closePane: () => void closePane($),
903 openUrl: url => void openUrl($, url),
904 copyText: text => void $.ui.copy({ text, surface }),
905 rerunRun: runId => void rerunRun($, live, runId),
906 ciToClaude: runId => void runToClaude($, runId),
907 }
908}
909
910/** Puts the failed-check reference into the prompt box for the person to send or edit. */
911async function ciToClaude($: EngineInterface, subject: Subject): Promise<string> {
912 const { snapshot } = await subjectSnapshot($, subject)
913 if (snapshot.kind !== 'pr') return 'No pull request for this branch.'
914 const text = ciReference(snapshot, await read($, jobsState))
915 if (text === null) return 'No failed checks.'
916 const filled = await $.prompt.fill({ text, mode: 'insert' })
917 const message = filled.isFilled ? 'Failed checks added to the prompt.' : 'The prompt could not take the failed checks.'
918 await $.ui.toast(message)
919 return message
920}
921
922/**
923 * Re-runs only the failed jobs of the failed Actions runs of the head commit. No
924 * confirmation: it starts CI again, it changes no code and merges nothing. Never
925 * retries; the refresh afterwards shows the queued jobs.
926 */
927async function rerunFailed($: EngineInterface, live: Live | null, subject: Subject): Promise<string> {
928 if (rerunGuard.isBusy) return 'A re-run is in progress, try again in a moment.'
929 rerunGuard.isBusy = true
930 try {
931 const { snapshot } = await subjectSnapshot($, subject)
932 if (snapshot.kind !== 'pr') return 'No pull request for this branch.'
933 const { pr, location, account } = snapshot
934 const runIds = rerunnableRunIds(pr)
935 if (runIds.length === 0) return 'No failed runs to re-run.'
936 const exec: Exec = (argv, init) => $.process.run(argv, init)
937 const summary = await executeRerun(exec, account.login, location, runIds)
938 const label = `PR #${pr.number}`
939 let message: string
940 if (summary.failed.length === 0) message = `${label}: re-running failed jobs in ${count(summary.ok.length, 'run', 'runs')}`
941 else if (summary.ok.length === 0) message = `${label}: could not re-run, ${summary.failed[0]?.message ?? 'gh failed'}`
942 else message = `${label}: re-running failed jobs in ${count(summary.ok.length, 'run', 'runs')}, ${summary.failed.length} failed: ${summary.failed[0]?.message ?? 'gh failed'}`
943 await $.ui.toast(message)
944 if (summary.ok.length > 0 && live !== null) void live.run('command')
945 return message
946 } finally {
947 rerunGuard.isBusy = false
948 }
949}
950
951/** The handler closures the band and pane call; each runs with the `$` of the hook that built it. */
952function handlersFor($: EngineInterface, live: Live | null, surface: RenderSurface | undefined, subject: Subject): Handlers {
953 return {
954 refresh: () => {
955 if (live !== null) void live.run('command')
956 },
957 openPane: () => void openPane($, live),
958 closePane: () => void closePane($),
959 openOnGitHub: () => void openOnGitHub($, subject),
960 copyUrl: () => void copyUrl($, surface, subject),
961 openUrl: url => void openUrl($, url),
962 ciToClaude: () => void ciToClaude($, subject),
963 rerunFailed: () => void rerunFailed($, live, subject),
964 requestMerge: () => void requestAction($, 'merge', subject),
965 requestEnqueue: () => void requestAction($, 'enqueue', subject),
966 requestDequeue: () => void requestAction($, 'dequeue', subject),
967 requestAutoMerge: () => void requestAutoMerge($, subject),
968 requestAdminMerge: () => void requestAction($, 'admin-merge', subject),
969 requestApprove: () => void requestAction($, 'approve', subject),
970 requestChanges: () => void requestAction($, 'request-changes', subject),
971 requestReady: () => void requestAction($, 'ready-for-review', subject),
972 setReviewBody: text => void setReviewBody($, text),
973 confirmPending: (isSecondStep, drawnBody) => void confirmPending($, live, isSecondStep === true, drawnBody),
974 cancelPending: () => void cancelPending($),
975 selectMethod: method => void selectMethod($, method),
976 selectPr: number => void selectPr($, live, number),
977 back: () => void back($, live),
978 openList: () => void openList($, live),
979 openActions: () => void (async () => {
980 if (await isConfirming($)) return
981 await enterTab($, live, 'actions', null, false, true)
982 })(),
983 // Only the pane's pipeline and Linked issues sections are buttons; the band has none of them.
984 ...(subject === 'pane'
985 ? {
986 openLinkedIssue: (number: number) => void openIssue($, live, number, false),
987 openRun: (runId: number) => void openRun($, live, runId, false),
988 }
989 : {}),
990 }
991}
992
993export const register: Register = (on, options) => {
994 const config = configOf(options)
995 const palette = paletteFor(options.colors)
996 const configured = commandNameOf(options.command)
997 settings.isMainShown = !(typeof options.mainStatus === 'string' && options.mainStatus.trim().toLowerCase() === 'off')
998 const percent = Number(options.paneWidthPercent)
999 settings.paneWidthPercent = Number.isFinite(percent) && percent >= 0 && percent <= 90 ? percent : DEFAULT_PANE_WIDTH_PERCENT
1000 /** The names this mod answers to; set by session.start, empty before. */
1001 let activeNames: string[] = []
1002 /** Names this mod registered itself, so a second session.start (hot reload) does not read them as taken. */
1003 const ownNames = new Set<string>()
1004 const accountConfig = parseAccountConfig(typeof options.accounts === 'string' ? options.accounts : '')
1005 let live: Live | null = null
1006
1007 on('session.start', async ($, e, next) => {
1008 // A second start (hot reload, resume) must not leave the first one's timers running next to its own.
1009 cancelTimers()
1010 generation += 1
1011 tabsRepo = null
1012 mainCache.repo = null
1013 mainCache.etag = null
1014 const mine = generation
1015 const isCurrent = (): boolean => mine === generation
1016 try {
1017 await update($, pendingState, () => null)
1018 await update($, outcomeState, () => null)
1019 } catch {
1020 // a fresh session starts without a pending action either way
1021 }
1022 // A skill or another command of the same name hides the mod's: register the configured name only when it is free.
1023 let taken: CommandInfo[] = []
1024 try {
1025 taken = await $.command.list()
1026 } catch {
1027 // without the list, register as if nothing were taken
1028 }
1029 const isTaken = (name: string): boolean =>
1030 !ownNames.has(name) && taken.some(command => command.name === name && !(command.source === 'plugin' && (command.plugin === PLUGIN_NAME || command.plugin?.startsWith(`${PLUGIN_NAME}@`) === true)))
1031 const wanted = isTaken(configured) ? [FALLBACK_COMMAND] : [configured, FALLBACK_COMMAND]
1032 // /gh is one more name for the same command; a taken one is skipped without a word.
1033 const names = isTaken(EXTRA_COMMAND) ? wanted : [...wanted, EXTRA_COMMAND]
1034 const registeredNow: string[] = []
1035 for (const name of [...new Set(names)]) {
1036 try {
1037 await $.command.register({ name, description: 'Show the pull request status of this branch, the issues and the Actions runs', argumentHint: ARGUMENT_HINT })
1038 registeredNow.push(name)
1039 ownNames.add(name)
1040 } catch {
1041 // the pane and band still work without the command
1042 }
1043 }
1044 activeNames = registeredNow
1045 if (!wanted.includes(configured) && configured !== FALLBACK_COMMAND) {
1046 try {
1047 await $.ui.toast(`gh-status: /${configured} is taken here (skill or command), use /${FALLBACK_COMMAND}`)
1048 } catch {
1049 // a missing toast changes nothing
1050 }
1051 }
1052
1053 const exec: Exec = (argv, init) => $.process.run(argv, init)
1054 const memory: AccountMemory = {
1055 get: async owner => {
1056 const login = await $.store.get(`account:${owner.toLowerCase()}`)
1057 return typeof login === 'string' ? login : null
1058 },
1059 set: (owner, login) => $.store.set(`account:${owner.toLowerCase()}`, login),
1060 forget: owner => $.store.delete(`account:${owner.toLowerCase()}`),
1061 }
1062
1063 // Two refreshers, each with its own single flight, accounts and backoff: the branch's pull
1064 // request (the band) and the one picked from the list (the pane). The picked one idles
1065 // while nothing is picked, or while the pick is the branch's own pull request.
1066 const depsFor = (target: RefreshTarget): RefresherDeps => ({
1067 target,
1068 config,
1069 readCwd: () => $.session.cwd(),
1070 readLocation: cwd => readLocation(exec, cwd),
1071 resolveAccount: location => resolveAccount({ exec, memory }, location, accountConfig),
1072 fetchPullRequest: (location, login) => fetchPullRequest(exec, location, login),
1073 fetchPullRequestByNumber: (location, login, number) => fetchPullRequestByNumber(exec, location, login, number),
1074 fastCheck: (location, login, fastTarget, etags) => fastCheck(exec, location, login, fastTarget, etags),
1075 fetchJobs: (location, login, runIds) => fetchJobs(exec, location, login, runIds),
1076 isPaneOpen: async () => (await $.ui.panes()).some(pane => pane.id === PANE_ID),
1077 readState: async () => ({
1078 snapshot: await read($, snapshotState),
1079 jobs: await read($, jobsState),
1080 etags: await read($, etagsState),
1081 selected: await read($, selectedState),
1082 selectedSnapshot: await read($, selectedSnapshotState),
1083 }),
1084 writeState: async (patch, writer) => {
1085 if (patch.snapshot !== undefined) {
1086 const snapshot = patch.snapshot
1087 await update($, snapshotState, () => snapshot)
1088 }
1089 if (patch.selectedSnapshot !== undefined) {
1090 const selectedSnapshot = patch.selectedSnapshot
1091 await update($, selectedSnapshotState, () => selectedSnapshot)
1092 }
1093 if (patch.jobs !== undefined) {
1094 const jobs = patch.jobs
1095 await update($, jobsState, () => jobs)
1096 }
1097 if (patch.rateLimit !== undefined) {
1098 const rateLimit = patch.rateLimit
1099 await update($, rateLimitState, () => rateLimit)
1100 }
1101 if (patch.etags !== undefined) {
1102 const etags = patch.etags
1103 await update($, etagsState, (stored: Record<string, string>) => mergeEtags(stored, etags, writer))
1104 }
1105 },
1106 now: () => $.clock.now(),
1107 toast: async text => {
1108 const prefix = ownToast.prefix
1109 if (prefix !== null && text.startsWith(prefix)) {
1110 ownToast.prefix = null
1111 if ((await $.clock.now()) <= ownToast.untilMs) return
1112 }
1113 await $.ui.toast(text)
1114 },
1115 })
1116 const branchRefresher = createRefresher(depsFor('branch'))
1117 const selectedRefresher = createRefresher(depsFor('selected'))
1118
1119 const arm = async (): Promise<void> => {
1120 timers.branch?.cancel()
1121 timers.branch = null
1122 if (!isCurrent()) return
1123 let delay = nextDelayMs({ kind: 'loading', at: '' }, null, config)
1124 try {
1125 const rateLimit = await read($, rateLimitState)
1126 delay = nextDelayMs(await read($, snapshotState), rateLimit, config)
1127 const picked = await read($, selectedSnapshotState)
1128 if (picked !== null) delay = Math.min(delay, nextDelayMs(picked, rateLimit, config))
1129 } catch {
1130 // keep the slow default
1131 }
1132 if (!isCurrent()) return
1133 timers.branch = $.clock.after(delay, () => void run('timer'))
1134 }
1135 const run = async (reason: RefreshReason): Promise<void> => {
1136 await Promise.all([branchRefresher.refresh(reason), selectedRefresher.refresh(reason)])
1137 // A pick or pending write made in another repository does not follow the session into this one.
1138 try {
1139 const slug = snapshotSlug(await read($, snapshotState))
1140 if (isCurrent() && slug !== null) await dropIfRepoChanged($, slug)
1141 } catch {
1142 // the state is gone with the session; arm() copes the same way
1143 }
1144 await arm()
1145 }
1146
1147 // The list and the counts read the repository of the directory with the account the branch target resolved.
1148 // Each has its own single flight and timer; a request that arrives during a read joins it.
1149 type RepoTarget = { location: RepoLocation; account: AccountChoice }
1150 const repoTarget = async (): Promise<RepoTarget | string> => {
1151 const location = await readLocation(exec, await $.session.cwd())
1152 if (location === null || location.owner === null || location.repo === null) return 'no GitHub repository here'
1153 const branch = await read($, snapshotState)
1154 if ((branch.kind === 'pr' || branch.kind === 'no-pr') && branch.location.owner?.toLowerCase() === location.owner.toLowerCase() && branch.location.repo?.toLowerCase() === location.repo.toLowerCase()) {
1155 return { location, account: branch.account }
1156 }
1157 const resolved = await resolveAccount({ exec, memory }, location, accountConfig)
1158 return 'login' in resolved ? { location, account: resolved } : 'no GitHub account can read this repository'
1159 }
1160
1161 let isListPolling = false
1162 let listFlight: Promise<void> | null = null
1163 const stopList = (): void => {
1164 isListPolling = false
1165 if (isCurrent()) {
1166 timers.list?.cancel()
1167 timers.list = null
1168 }
1169 }
1170 const readListOnce = async (): Promise<void> => {
1171 const target = await repoTarget()
1172 const nowMs = await $.clock.now()
1173 const at = new Date(nowMs).toISOString()
1174 if (typeof target === 'string') {
1175 const none: PrList = { kind: 'error', at, message: target }
1176 await update($, listState, () => none)
1177 return
1178 }
1179 const fetched = await fetchList(exec, target.location, target.account.login)
1180 await update($, listState, (current: PrList | null) => listAfter(current, fetched, at, target.location, target.account))
1181 if (fetched.kind === 'ok' && fetched.rateLimit !== null) {
1182 const rateLimit = fetched.rateLimit
1183 await update($, rateLimitState, () => rateLimit)
1184 }
1185 }
1186 const runList = (): Promise<void> => {
1187 isListPolling = true
1188 if (listFlight !== null) return listFlight
1189 const flight = (async (): Promise<void> => {
1190 try {
1191 await readListOnce()
1192 } catch (error) {
1193 const message = error instanceof Error ? error.message : String(error)
1194 const broken: PrList = { kind: 'error', at: new Date(await $.clock.now()).toISOString(), message: message.slice(0, 300) }
1195 await update($, listState, (current: PrList | null) => current ?? broken)
1196 } finally {
1197 listFlight = null
1198 }
1199 if (!isCurrent()) return
1200 timers.list?.cancel()src/actions.ts 453 lines1// Write actions on the pull request: what may be offered, the dialog's pending
2// state, the exact gh arguments, and the one place that executes them.
3//
4// Nothing here runs by itself. register.tsx calls `execute` only from the
5// dialog's confirm press, and `execute` re-checks the head commit first. Every
6// write binds the commit the person saw (`--match-head-commit`, `expectedHeadOid`),
7// so GitHub itself refuses if the branch moved in between. These flags are
8// never built: `--delete-branch`, `--auto`; `--admin` only for the admin action.
9
10import { runGh, tokenFor } from './gh'
11import type { Exec, GhResult } from './gh'
12import type { ActionOutcome, Blocker, CheckState, JobDetail, MergeMethod, PendingAction, PullRequest, RepoLocation, Snapshot } from './model'
13import { FAILED_STATES, deriveBlockers, deriveStatus } from './status'
14import { SLUG_PATTERN } from './text'
15
16export type PrSnapshot = Extract<Snapshot, { kind: 'pr' }>
17export type ActionKind = PendingAction['kind']
18
19export const HEAD_CHANGED = 'new commits since you asked to confirm, review again'
20export const RULES_CHANGED = 'rules changed, review again'
21export const RECHECK_FAILED = 'could not re-check the pull request, nothing was done'
22export const CANCELLED = 'the dialog was closed, nothing was done'
23export const CHANGES_NEED_TEXT = 'write what should change first'
24
25/** Presses this soon after the dialog (or its admin step) opened are stray keystrokes. */
26export const MIN_DIALOG_MS = 600
27
28const WRITE_TIMEOUT_MS = 30_000
29
30const METHOD_FLAG: Record<MergeMethod, string> = { merge: '--merge', squash: '--squash', rebase: '--rebase' }
31const METHOD_ENUM: Record<MergeMethod, string> = { merge: 'MERGE', squash: 'SQUASH', rebase: 'REBASE' }
32
33// Input field names verified against GitHub's schema by introspection:
34// EnqueuePullRequestInput { pullRequestId, expectedHeadOid, jump },
35// DequeuePullRequestInput { id }, EnablePullRequestAutoMergeInput { pullRequestId,
36// mergeMethod, expectedHeadOid, ... }, DisablePullRequestAutoMergeInput { pullRequestId },
37// AddPullRequestReviewInput { pullRequestId!, commitOID, body, event, comments, threads }
38// with event one of COMMENT, APPROVE, REQUEST_CHANGES, DISMISS,
39// MarkPullRequestReadyForReviewInput { pullRequestId }.
40export const ENQUEUE_MUTATION =
41 'mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!) { enqueuePullRequest(input: {pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid}) { mergeQueueEntry { position } } }'
42export const DEQUEUE_MUTATION = 'mutation($id: ID!) { dequeuePullRequest(input: {id: $id}) { mergeQueueEntry { position } } }'
43export const AUTO_MERGE_ON_MUTATION =
44 'mutation($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!, $expectedHeadOid: GitObjectID!) { enablePullRequestAutoMerge(input: {pullRequestId: $pullRequestId, mergeMethod: $mergeMethod, expectedHeadOid: $expectedHeadOid}) { pullRequest { number } } }'
45export const AUTO_MERGE_OFF_MUTATION =
46 'mutation($pullRequestId: ID!) { disablePullRequestAutoMerge(input: {pullRequestId: $pullRequestId}) { pullRequest { number } } }'
47
48export const REVIEW_MUTATION =
49 'mutation($pullRequestId: ID!, $commitOID: GitObjectID!, $event: PullRequestReviewEvent!, $body: String!) { addPullRequestReview(input: {pullRequestId: $pullRequestId, commitOID: $commitOID, event: $event, body: $body}) { pullRequestReview { state } } }'
50export const READY_MUTATION =
51 'mutation($pullRequestId: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $pullRequestId}) { pullRequest { number } } }'
52
53const OID_PATTERN = /^[0-9a-f]{40}$/
54
55/** True for a full 40-digit lowercase hex git object id. */
56export const isValidOid = (oid: string): boolean => OID_PATTERN.test(oid)
57
58function requireOid(oid: string): string {
59 if (!isValidOid(oid)) throw new Error('no valid head commit to bind the write to')
60 return oid
61}
62
63/**
64 * Start of the state-transition toast (src/status.ts) that reports what `kind`
65 * just did, or null when no transition toast exists for it. The mod's own
66 * success toast already says it, so register.tsx drops the matching one.
67 */
68export function transitionToastPrefix(kind: ActionKind, prNumber: number): string | null {
69 const label = `PR #${prNumber}: `
70 switch (kind) {
71 case 'merge':
72 case 'admin-merge':
73 return `${label}merged`
74 case 'enqueue':
75 return `${label}added to merge queue`
76 case 'dequeue':
77 return `${label}removed from merge queue`
78 default:
79 return null
80 }
81}
82
83/** True while a press is too close to the dialog's opening to be deliberate. */
84export const isTooEarly = (pending: PendingAction, nowMs: number): boolean => nowMs - pending.openedAt < MIN_DIALOG_MS
85
86/** `owner/repo` of a location, or null when it has none. */
87export function repoSlug(location: RepoLocation): string | null {
88 return location.owner === null || location.repo === null ? null : `${location.owner}/${location.repo}`
89}
90
91/** The merge method to start from: squash if the repository allows it, else the first allowed one. */
92export function defaultMethod(allowed: readonly MergeMethod[]): MergeMethod | null {
93 if (allowed.includes('squash')) return 'squash'
94 return allowed[0] ?? null
95}
96
97/** `gh pr merge` arguments, bound to the head commit. `--admin` only when `isAdmin`. Throws without a valid commit or repository. */
98export function mergeArgs(target: { prNumber: number; repo: string; headOid: string }, method: MergeMethod, isAdmin: boolean): string[] {
99 if (!SLUG_PATTERN.test(target.repo)) throw new Error('no GitHub repository to merge in')
100 const args = ['pr', 'merge', String(target.prNumber), METHOD_FLAG[method], '--match-head-commit', requireOid(target.headOid), '-R', target.repo]
101 if (isAdmin) args.push('--admin')
102 return args
103}
104
105function graphqlArgs(query: string, variables: Array<[string, string]>): string[] {
106 // -f, not -F: every value is passed as the string it is.
107 return ['api', 'graphql', '-f', `query=${query}`, ...variables.flatMap(([name, value]) => ['-f', `${name}=${value}`])]
108}
109
110export const enqueueArgs = (prId: string, headOid: string): string[] =>
111 graphqlArgs(ENQUEUE_MUTATION, [['pullRequestId', prId], ['expectedHeadOid', requireOid(headOid)]])
112
113export const dequeueArgs = (prId: string): string[] => graphqlArgs(DEQUEUE_MUTATION, [['id', prId]])
114
115export const autoMergeOnArgs = (prId: string, method: MergeMethod, headOid: string): string[] =>
116 graphqlArgs(AUTO_MERGE_ON_MUTATION, [['pullRequestId', prId], ['mergeMethod', METHOD_ENUM[method]], ['expectedHeadOid', requireOid(headOid)]])
117
118/** `gh run rerun` arguments for one run: only its failed jobs. Throws without a valid repository or run id. */
119export function rerunArgs(runId: number, location: RepoLocation): string[] {
120 const slug = repoSlug(location)
121 if (slug === null || !SLUG_PATTERN.test(slug)) throw new Error('no GitHub repository to re-run in')
122 if (!Number.isSafeInteger(runId) || runId <= 0) throw new Error('no valid run to re-run')
123 return ['run', 'rerun', String(runId), '--failed', '-R', slug]
124}
125
126const REVIEW_EVENT = { approve: 'APPROVE', 'request-changes': 'REQUEST_CHANGES' } as const
127
128/** The text a review posts: an approval's blank comment is empty, anything else goes as typed. */
129export const reviewText = (kind: 'approve' | 'request-changes', body: string): string => (kind === 'approve' && body.trim() === '' ? '' : clampReviewText(body))
130
131/** Longest review text kept (GitHub's own limit is 65536). */
132export const MAX_REVIEW_CHARS = 65_000
133
134/** The text cut to MAX_REVIEW_CHARS, never between the two halves of a surrogate pair. */
135export function clampReviewText(text: string): string {
136 if (text.length <= MAX_REVIEW_CHARS) return text
137 const last = text.charCodeAt(MAX_REVIEW_CHARS - 1)
138 const isHighHalf = last >= 0xd800 && last <= 0xdbff
139 return text.slice(0, isHighHalf ? MAX_REVIEW_CHARS - 1 : MAX_REVIEW_CHARS)
140}
141
142/** Shown when the text of the confirmation changed between its drawing and the press. */
143export const TEXT_CHANGED = 'text changed, check it and press y again'
144
145/** True for a request for changes without any text to say what should change. */
146export const isReviewTextMissing = (pending: PendingAction): boolean => pending.kind === 'request-changes' && pending.body.trim() === ''
147
148/** `addPullRequestReview` arguments, bound to the head commit the person saw (`commitOID`). */
149export const reviewArgs = (prId: string, kind: 'approve' | 'request-changes', headOid: string, body: string): string[] =>
150 graphqlArgs(REVIEW_MUTATION, [['pullRequestId', prId], ['commitOID', requireOid(headOid)], ['event', REVIEW_EVENT[kind]], ['body', reviewText(kind, body)]])
151
152export const readyArgs = (prId: string): string[] => graphqlArgs(READY_MUTATION, [['pullRequestId', prId]])
153
154export const autoMergeOffArgs = (prId: string): string[] => graphqlArgs(AUTO_MERGE_OFF_MUTATION, [['pullRequestId', prId]])
155
156const sameLogin = (a: string | null, b: string): boolean => a !== null && b !== '' && a.toLowerCase() === b.toLowerCase()
157
158/**
159 * The write actions the person may start for this pull request, in display order.
160 * `viewerLogin` is the account that would act: reviews are for other people's
161 * open, non-draft pull requests, ready-for-review for the viewer's own drafts.
162 */
163export function availableActions(pr: PullRequest, viewerLogin = ''): ActionKind[] {
164 if (pr.state !== 'open') return []
165 // Every write binds the head commit; without a valid one nothing is offered.
166 if (!isValidOid(pr.headOid)) return []
167 const actions: ActionKind[] = []
168 const isMergeable = deriveStatus(pr, '', null).verdict === 'mergeable'
169 const hasMethod = pr.allowedMethods.length > 0
170 // With a merge queue, `gh pr merge` would silently enqueue or enable auto-merge: no plain merge then.
171 if (isMergeable && hasMethod && !pr.isDraft && !pr.isInMergeQueue && !pr.isMergeQueueEnabled) actions.push('merge')
172 if (pr.isMergeQueueEnabled && !pr.isInMergeQueue && !pr.isDraft) actions.push('enqueue')
173 if (pr.isInMergeQueue) actions.push('dequeue')
174 if (!isMergeable && hasMethod && !pr.isDraft && !pr.isInMergeQueue && pr.autoMerge === null) actions.push('auto-merge-on')
175 if (pr.autoMerge !== null && !pr.isInMergeQueue) actions.push('auto-merge-off')
176 const needsBypass = !isMergeable || pr.isMergeQueueEnabled
177 if (pr.viewerCanMergeAsAdmin && hasMethod && needsBypass && !pr.isDraft && !pr.isInMergeQueue) actions.push('admin-merge')
178 if (!pr.isDraft && pr.author !== null && viewerLogin !== '' && !sameLogin(pr.author, viewerLogin)) actions.push('approve', 'request-changes')
179 if (pr.isDraft && sameLogin(pr.author, viewerLogin)) actions.push('ready-for-review')
180 return actions
181}
182
183/** States of a check whose job `gh run rerun --failed` re-runs. action-required is left out: it waits for an approval, a re-run does not help. */
184const RERUNNABLE_STATES: readonly CheckState[] = ['failure', 'timed-out', 'cancelled']
185
186/** Whether the Actions run detail may re-run a run in this state: only a failed or timed-out one. */
187export function canRerunRun(state: CheckState): boolean {
188 return state === 'failure' || state === 'timed-out'
189}
190
191/**
192 * Distinct Actions run ids worth re-running: a check of the run failed, timed out
193 * or was cancelled, and no check of that same run is still queued or running
194 * (GitHub refuses to re-run a run in progress). Checks without a run id (other
195 * providers) are ignored. Only the head commit's checks count, never the merge
196 * queue's own (pr.mergeQueueEntry.checks), which GitHub starts and owns.
197 */
198export function rerunnableRunIds(pr: PullRequest): number[] {
199 const inProgress = new Set<number>()
200 for (const check of pr.checks) {
201 if (check.runId !== null && (check.state === 'queued' || check.state === 'running')) inProgress.add(check.runId)
202 }
203 const ids: number[] = []
204 for (const check of pr.checks) {
205 if (check.runId === null || !RERUNNABLE_STATES.includes(check.state)) continue
206 if (inProgress.has(check.runId) || ids.includes(check.runId)) continue
207 ids.push(check.runId)
208 }
209 return ids
210}
211
212export type RerunSummary = { ok: number[]; failed: Array<{ runId: number; message: string }> }
213
214/** Re-runs the failed jobs of each run once, as `login`, never retrying. Never rejects; every run is tried. */
215export async function executeRerun(exec: Exec, login: string, location: RepoLocation, runIds: readonly number[]): Promise<RerunSummary> {
216 const summary: RerunSummary = { ok: [], failed: [] }
217 for (const runId of runIds) {
218 let args: string[]
219 try {
220 args = rerunArgs(runId, location)
221 } catch (error) {
222 summary.failed.push({ runId, message: error instanceof Error ? error.message : 'the re-run could not be built' })
223 continue
224 }
225 const ran = await runGh(exec, args, { login, cwd: location.cwd, timeoutMs: WRITE_TIMEOUT_MS })
226 if (ran.isOk) summary.ok.push(runId)
227 else summary.failed.push({ runId, message: ran.message })
228 }
229 return summary
230}
231
232/** The rules an admin merge would bypass, as the person reads them. */
233export function bypassedRules(pr: PullRequest): Blocker[] {
234 const rules = deriveBlockers(pr)
235 if (pr.isMergeQueueEnabled) rules.push({ kind: 'branch-protection', text: 'merge queue', subjects: [] })
236 if (rules.length === 0) rules.push({ kind: 'unknown', text: 'branch protection rules', subjects: [] })
237 return rules
238}
239
240/**
241 * The dialog's pending action for `kind`, or null when this pull request does
242 * not offer it. `method` is validated against what the repository allows.
243 */
244export function buildPending(kind: ActionKind, snapshot: Snapshot, method?: MergeMethod, openedAt = 0, body = ''): PendingAction | null {
245 if (snapshot.kind !== 'pr') return null
246 const { pr, account, location } = snapshot
247 const repo = repoSlug(location)
248 if (repo === null || !SLUG_PATTERN.test(repo)) return null
249 if (!availableActions(pr, account.login).includes(kind)) return null
250 const chosen = method ?? defaultMethod(pr.allowedMethods)
251 if (chosen !== null && !pr.allowedMethods.includes(chosen)) return null
252 const base = { prNumber: pr.number, repo: repo.toLowerCase(), prId: pr.id, baseRefName: pr.baseRefName, login: account.login, openedAt }
253 const headOid = pr.headOid
254 switch (kind) {
255 case 'merge':
256 return chosen === null ? null : { ...base, kind, headOid, method: chosen }
257 case 'admin-merge':
258 return chosen === null ? null : { ...base, kind, headOid, method: chosen, bypassed: bypassedRules(pr), isSecondStep: false }
259 case 'enqueue':
260 return { ...base, kind, headOid }
261 case 'dequeue':
262 return { ...base, kind }
263 case 'auto-merge-on':
264 return chosen === null ? null : { ...base, kind, headOid, method: chosen }
265 case 'auto-merge-off':
266 return { ...base, kind }
267 case 'approve':
268 case 'request-changes':
269 return { ...base, kind, headOid, body }
270 case 'ready-for-review':
271 return { ...base, kind, headOid }
272 }
273}
274
275/** The pending action with another merge method; unchanged if it has none or the second admin step is up. */
276export function withMethod(pending: PendingAction, method: MergeMethod, allowed: readonly MergeMethod[]): PendingAction {
277 if (!allowed.includes(method)) return pending
278 if (pending.kind === 'merge' || pending.kind === 'auto-merge-on') return { ...pending, method }
279 if (pending.kind === 'admin-merge' && !pending.isSecondStep) return { ...pending, method }
280 return pending
281}
282
283/**
284 * What a confirm press does. `isSecondStep` is the admin step the pressed button
285 * was drawn for: a press drawn for another step than the live one is stale and
286 * ignored, so repeating the first step's key can never reach the final confirm.
287 * Only a press of a non-admin dialog or of the admin second step executes.
288 */
289export function confirmStep(pending: PendingAction, isSecondStep: boolean): 'ignore' | 'advance' | 'execute' {
290 if (pending.kind !== 'admin-merge') return 'execute'
291 if (isSecondStep !== pending.isSecondStep) return 'ignore'
292 return pending.isSecondStep ? 'execute' : 'advance'
293}
294
295function graphqlFailure(stdout: string): string | null {
296 try {
297 const answer: unknown = JSON.parse(stdout)
298 const errors = (answer as { errors?: unknown } | null)?.errors
299 if (Array.isArray(errors) && errors.length > 0) {
300 const message = (errors[0] as { message?: unknown } | null)?.message
301 return typeof message === 'string' ? message : 'GraphQL error'
302 }
303 } catch {
304 // an unreadable body after exit 0 is not an error we can name
305 }
306 return null
307}
308
309function successMessage(pending: PendingAction): string {
310 switch (pending.kind) {
311 case 'merge':
312 return `PR #${pending.prNumber} merged`
313 case 'admin-merge':
314 return `PR #${pending.prNumber} merged as admin`
315 case 'enqueue':
316 return `PR #${pending.prNumber} added to merge queue`
317 case 'dequeue':
318 return `PR #${pending.prNumber} removed from merge queue`
319 case 'auto-merge-on':
320 return `auto-merge enabled for PR #${pending.prNumber}`
321 case 'auto-merge-off':
322 return `auto-merge disabled for PR #${pending.prNumber}`
323 case 'approve':
324 return `PR #${pending.prNumber} approved`
325 case 'request-changes':
326 return `changes requested on PR #${pending.prNumber}`
327 case 'ready-for-review':
328 return `PR #${pending.prNumber} is ready for review`
329 }
330}
331
332/**
333 * True when GitHub clearly said no: a GraphQL `errors` body that is not a server fault, or an HTTP 4xx
334 * (408 is a timeout, not a refusal). A run that never started (no gh, no token) wrote nothing either.
335 */
336function isClearRefusal(ran: Extract<GhResult, { isOk: false }>): boolean {
337 if (ran.kind === 'gh-missing') return true
338 if (ran.kind === 'auth' && ran.exitCode === null) return true
339 if (ran.kind === 'server' || ran.kind === 'network' || ran.kind === 'timeout') return false
340 if (graphqlFailure(ran.stdout) !== null) return true
341 return /\bhttp 4(?!08)\d\d\b/i.test(ran.message)
342}
343
344/** True when a failed run may still have reached GitHub: no clear refusal came back. */
345function isResultUnknown(kind: ActionKind, ran: Extract<GhResult, { isOk: false }>): boolean {
346 if (kind === 'approve' || kind === 'request-changes' || kind === 'ready-for-review') {
347 // Reviews and ready-for-review cannot be taken back or repeated safely: only a clear refusal is known.
348 return !isClearRefusal(ran)
349 }
350 if (ran.kind === 'timeout' || ran.kind === 'network' || ran.kind === 'server') return true
351 return ran.kind === 'failed' && ran.exitCode === null
352}
353
354/**
355 * Runs the confirmed action once, as `pending.login`, never retrying. Before any
356 * write it checks the fresh snapshot `latest`: same pull request (node id and
357 * number), same repository, same account, same head commit, action still offered,
358 * and for the admin second step the same bypassed rules. Every id in the gh
359 * arguments comes from `pending`, never from `latest`. Any mismatch is an outcome
360 * with isOk false and no gh write. `at` is the ISO time of the call;
361 * `isStillPending` is asked right before the write, and a false answer does nothing.
362 */
363export async function execute(
364 exec: Exec,
365 pending: PendingAction,
366 latest: Snapshot | null,
367 at: string,
368 isStillPending: () => Promise<boolean> = async () => true,
369): Promise<ActionOutcome> {
370 const refuse = (message: string, isUnknown = false): ActionOutcome => ({ kind: pending.kind, isOk: false, message, at, ...(isUnknown ? { isUnknown } : {}) })
371 if ('headOid' in pending && !isValidOid(pending.headOid)) return refuse('no valid head commit to bind the write to, review again')
372 if (isReviewTextMissing(pending)) return refuse(CHANGES_NEED_TEXT)
373 if (latest === null || latest.kind !== 'pr') return refuse('no pull request to act on, review again')
374 const { pr, location, account } = latest
375 const slug = repoSlug(location)
376 if (slug === null) return refuse('no GitHub repository to act in')
377 if (slug.toLowerCase() !== pending.repo.toLowerCase()) return refuse(`the repository changed to ${slug}, review again`)
378 if (pr.id !== pending.prId || pr.number !== pending.prNumber) return refuse(`PR #${pending.prNumber} is no longer the pull request you saw, review again`)
379 if (account.login !== pending.login) return refuse(`the account changed to @${account.login}, review again`)
380 if ('headOid' in pending && pending.headOid !== pr.headOid) return refuse(HEAD_CHANGED)
381 if (pending.kind === 'admin-merge' && !pending.isSecondStep) return refuse('admin merge needs its second confirmation')
382 if (!availableActions(pr, account.login).includes(pending.kind)) return refuse('this action is no longer available for the pull request, review again')
383 if ('method' in pending && !pr.allowedMethods.includes(pending.method)) return refuse(`${pending.method} is not allowed in this repository`)
384 if (pending.kind === 'admin-merge' && JSON.stringify(bypassedRules(pr)) !== JSON.stringify(pending.bypassed)) return refuse(RULES_CHANGED)
385
386 let args: string[]
387 try {
388 switch (pending.kind) {
389 case 'merge':
390 case 'admin-merge':
391 args = mergeArgs(pending, pending.method, pending.kind === 'admin-merge')
392 break
393 case 'enqueue':
394 args = enqueueArgs(pending.prId, pending.headOid)
395 break
396 case 'dequeue':
397 args = dequeueArgs(pending.prId)
398 break
399 case 'auto-merge-on':
400 args = autoMergeOnArgs(pending.prId, pending.method, pending.headOid)
401 break
402 case 'auto-merge-off':
403 args = autoMergeOffArgs(pending.prId)
404 break
405 case 'approve':
406 case 'request-changes':
407 args = reviewArgs(pending.prId, pending.kind, pending.headOid, pending.body)
408 break
409 case 'ready-for-review':
410 args = readyArgs(pending.prId)
411 break
412 }
413 } catch (error) {
414 return refuse(error instanceof Error ? error.message : 'the write could not be built')
415 }
416
417 // Warm the token first, so the last check below and the write are not separated by a process spawn.
418 await tokenFor(exec, pending.login)
419 if (!(await isStillPending())) return refuse(CANCELLED)
420 const ran = await runGh(exec, args, { login: pending.login, cwd: location.cwd, timeoutMs: WRITE_TIMEOUT_MS })
421 if (!ran.isOk) return refuse(ran.message, isResultUnknown(pending.kind, ran))
422 const failure = pending.kind === 'merge' || pending.kind === 'admin-merge' ? null : graphqlFailure(ran.stdout)
423 if (failure !== null) return refuse(failure)
424 return { kind: pending.kind, isOk: true, message: successMessage(pending), at }
425}
426
427const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
428
429/**
430 * The text `[f]` puts into the prompt: where the failure is and how to read it,
431 * never the log itself. Null when no check failed.
432 */
433export function ciReference(snapshot: PrSnapshot, jobs: JobDetail[]): string | null {
434 const { pr, location } = snapshot
435 const failed = pr.checks.filter(check => FAILED_STATES.includes(check.state))
436 if (failed.length === 0) return null
437 const slug = repoSlug(location) ?? ''
438 const lines = [`CI failed on ${slug} PR #${pr.number} (${pr.url})`, `Head commit: ${pr.headOid}`, 'Failed checks:']
439 for (const check of failed) {
440 const job = jobs.find(candidate => (check.jobId !== null && candidate.jobId === check.jobId) || (check.runId !== null && candidate.runId === check.runId && candidate.name === check.name))
441 const parts = [`${oneLine(check.name)}${check.isRequired ? ' (required)' : ''}: ${check.state}`]
442 if (check.runId !== null) parts.push(`run ${check.runId}`)
443 const jobId = check.jobId ?? job?.jobId ?? null
444 if (jobId !== null) parts.push(`job ${jobId}`)
445 if (job?.failedStep) parts.push(`failed step "${oneLine(job.failedStep)}"`)
446 lines.push(`- ${parts.join(', ')}`)
447 if (check.runId !== null) lines.push(` gh run view ${check.runId} --log-failed -R ${slug}`)
448 else if (check.url !== null) lines.push(` details: ${check.url}`)
449 }
450 lines.push('Please investigate these failures and fix them.')
451 return lines.join('\n')
452}
453src/etag.ts 102 lines1// The fast change detector. A conditional GET (If-None-Match) that GitHub
2// answers with 304 Not Modified does not count against the primary rate limit
3// (measured: x-ratelimit-used stays put across 304s and rises by 1 per 200),
4// so this can run every few seconds. Only a 200 means something changed.
5//
6// `gh api -i` with a matching If-None-Match exits 1, prints `gh: HTTP 304` on
7// stderr, and keeps the status line and headers on stdout. runGh keeps stdout
8// on failure, so a 304 is recognised from stdout, not from the exit code.
9
10import { runGh } from './gh'
11import type { Exec } from './gh'
12import type { RepoLocation } from './model'
13
14/** The pull request and head commit the fast check watches. */
15export type FastTarget = {
16 prNumber: number
17 headOid: string
18}
19
20export type FastCheckResult = {
21 /** A watched resource changed, or was seen for the first time. */
22 isChanged: boolean
23 /** ETags per request path, to store for the next call. */
24 etags: Record<string, string>
25 /** At least one request failed for a reason other than 304. */
26 isFailed: boolean
27}
28
29export type IncludeOutput = {
30 /** HTTP status; 0 when stdout has no status line. */
31 status: number
32 etag: string | null
33 body: string
34}
35
36/** Parses `gh api -i` output: status line, headers, blank line, body. Pure. */
37export function parseIncludeOutput(stdout: string): IncludeOutput {
38 const text = stdout.replace(/\r\n/g, '\n')
39 const match = /^HTTP\/[\d.]+\s+(\d{3})/.exec(text)
40 if (match === null) return { status: 0, etag: null, body: text }
41 const split = text.indexOf('\n\n')
42 const head = split === -1 ? text : text.slice(0, split)
43 const body = split === -1 ? '' : text.slice(split + 2)
44 let etag: string | null = null
45 for (const line of head.split('\n').slice(1)) {
46 const colon = line.indexOf(':')
47 if (colon === -1) continue
48 if (line.slice(0, colon).trim().toLowerCase() === 'etag') etag = line.slice(colon + 1).trim()
49 }
50 return { status: Number(match[1]), etag, body }
51}
52
53/** The request paths a pull request's live state shows up in. */
54export function fastPaths(location: RepoLocation, target: FastTarget): string[] {
55 const base = `repos/${location.owner ?? ''}/${location.repo ?? ''}`
56 return [
57 `${base}/commits/${target.headOid}/check-runs?per_page=100`,
58 `${base}/commits/${target.headOid}/status`,
59 // Its updated_at moves on every review and comment, and its body carries mergeable_state.
60 `${base}/pulls/${target.prNumber}`,
61 // Both comment lists carry body and updated_at, so an edit in place changes their ETag.
62 // GitHub lists comments oldest first: an edit is only seen within the first 100 comments of each list.
63 `${base}/issues/${target.prNumber}/comments?per_page=100`,
64 `${base}/pulls/${target.prNumber}/comments?per_page=100`,
65 ]
66}
67
68/** GitHub sends weak ETags (W/"x") on 200 and strong ones on 304; compare the tag alone. */
69function tagOf(etag: string): string {
70 return etag.replace(/^W\//, '')
71}
72
73type Probe = { path: string; etag: string | null; isChanged: boolean; isFailed: boolean }
74
75async function probe(exec: Exec, path: string, login: string, cwd: string, stored: string | undefined): Promise<Probe> {
76 const args = stored === undefined ? ['api', '-i', path] : ['api', '-i', '-H', `If-None-Match: ${stored}`, path]
77 const ran = await runGh(exec, args, { login, cwd })
78 const parsed = parseIncludeOutput(ran.stdout)
79 if (parsed.status === 304) return { path, etag: stored ?? null, isChanged: false, isFailed: false }
80 if (!ran.isOk || parsed.status !== 200) return { path, etag: stored ?? null, isChanged: false, isFailed: true }
81 const isChanged = parsed.etag === null || stored === undefined || tagOf(parsed.etag) !== tagOf(stored)
82 return { path, etag: parsed.etag ?? stored ?? null, isChanged, isFailed: false }
83}
84
85/**
86 * Asks GitHub, with conditional requests, whether anything about the pull
87 * request changed since `etags` were taken. A first call has no ETags and
88 * counts as changed. Never rejects.
89 */
90export async function fastCheck(exec: Exec, location: RepoLocation, login: string, target: FastTarget, etags: Record<string, string>): Promise<FastCheckResult> {
91 const probes = await Promise.all(fastPaths(location, target).map(path => probe(exec, path, login, location.cwd, etags[path])))
92 const next: Record<string, string> = {}
93 for (const result of probes) {
94 if (result.etag !== null) next[result.path] = result.etag
95 }
96 return {
97 isChanged: probes.some(result => result.isChanged),
98 etags: next,
99 isFailed: probes.some(result => result.isFailed),
100 }
101}
102src/gh.ts 128 lines1// The one place gh-status starts `gh`. Every call names the account it runs
2// as; the account's token reaches only that child, as GH_TOKEN in its env.
3// `gh auth switch` is never run, so the person's global gh login is untouched.
4
5import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
6
7/**
8 * Runs a host command. register.tsx builds it from $.process.run, since $ is
9 * never passed across an import; tests pass a fake.
10 */
11export type Exec = (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
12
13export type GhErrorKind =
14 | 'gh-missing'
15 | 'not-found'
16 | 'auth'
17 | 'rate-limited'
18 | 'server'
19 | 'network'
20 | 'timeout'
21 | 'failed'
22
23export type GhResult =
24 | { isOk: true; stdout: string; stderr: string }
25 | { isOk: false; kind: GhErrorKind; exitCode: number | null; message: string; stdout: string }
26
27export type GhOptions = {
28 /** Login to run as; null runs as gh's active account. */
29 login: string | null
30 /** Working directory; the session's when absent. */
31 cwd?: string
32 /** Text written to stdin. */
33 stdin?: string
34 timeoutMs?: number
35}
36
37const DEFAULT_TIMEOUT_MS = 20_000
38const TOKEN_TIMEOUT_MS = 5_000
39
40/** Tokens per login for this module instance; never written to $.state or $.store. */
41const tokens = new Map<string, string>()
42
43const SERVER_ERROR = /\bhttp (500|502|503|504)\b|bad gateway|service unavailable|gateway time-?out|server error|invalid character '<'|something went wrong while executing your query|couldn't respond to your request in time/
44
45/** Exit codes of a process that was killed or timed out (timeout(1), SIGKILL, SIGTERM). */
46const TIMEOUT_EXIT_CODES: readonly number[] = [124, 137, 143]
47
48const NETWORK_ERROR = /could not resolve host|connection refused|connection reset|\beof\b|tls handshake|broken pipe|unexpected end|network|timeout|timed out/
49
50/**
51 * Classifies a failed gh run by its stderr, and by its stdout when that is an
52 * HTML error page. Pure, exported for tests. A 5xx or an HTML body is 'server':
53 * GitHub, not the request, is at fault, so it is worth retrying.
54 */
55export function classifyGhError(stderr: string, stdout = '', exitCode: number | null = null): GhErrorKind {
56 const text = stderr.toLowerCase()
57 // A killed or timed-out process says nothing about GitHub's answer: the write may have landed.
58 if (exitCode !== null && TIMEOUT_EXIT_CODES.includes(exitCode)) return 'timeout'
59 // Server and connection faults first: their text can also contain "not found" or "authentication".
60 // An HTML body is GitHub's error page, unless stderr names a 4xx status for it.
61 const isErrorPage = stdout.trimStart().startsWith('<') && !/\bhttp 4\d\d\b/.test(text)
62 if (SERVER_ERROR.test(text) || isErrorPage) return 'server'
63 if (NETWORK_ERROR.test(text)) return 'network'
64 if (text.includes('could not resolve to a repository') || text.includes('http 404') || text.includes('not found')) {
65 return 'not-found'
66 }
67 if (text.includes('rate limit') || text.includes('http 429') || text.includes('secondary rate')) {
68 return 'rate-limited'
69 }
70 if (text.includes('http 401') || text.includes('bad credentials') || text.includes('authentication') || text.includes('gh auth login')) {
71 return 'auth'
72 }
73 return 'failed'
74}
75
76/** Resolves the token for a login, cached per module instance; null if gh has none. */
77export async function tokenFor(exec: Exec, login: string): Promise<string | null> {
78 const cached = tokens.get(login)
79 if (cached !== undefined) return cached
80 try {
81 const ran = await exec(['gh', 'auth', 'token', '-u', login], { timeoutMs: TOKEN_TIMEOUT_MS })
82 const token = ran.stdout.trim()
83 if (ran.exitCode !== 0 || token === '') return null
84 tokens.set(login, token)
85 return token
86 } catch {
87 return null
88 }
89}
90
91/** Forgets cached tokens, e.g. after an auth failure. */
92export function forgetTokens(): void {
93 tokens.clear()
94}
95
96/** Runs `gh <args>` as the given account. Never rejects. */
97export async function runGh(exec: Exec, args: readonly string[], options: GhOptions): Promise<GhResult> {
98 const env: Record<string, string> = { GH_PROMPT_DISABLED: '1', NO_COLOR: '1' }
99 if (options.login !== null) {
100 const token = await tokenFor(exec, options.login)
101 if (token === null) {
102 return { isOk: false, kind: 'auth', exitCode: null, message: `gh has no token for ${options.login}`, stdout: '' }
103 }
104 env.GH_TOKEN = token
105 }
106 try {
107 const ran = await exec(['gh', ...args], {
108 cwd: options.cwd,
109 env,
110 stdin: options.stdin,
111 timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
112 })
113 if (ran.exitCode === 0) return { isOk: true, stdout: ran.stdout, stderr: ran.stderr }
114 const kind = classifyGhError(ran.stderr, ran.stdout, ran.exitCode)
115 if (kind === 'auth') forgetTokens()
116 return { isOk: false, kind, exitCode: ran.exitCode, message: firstLine(ran.stderr), stdout: ran.stdout }
117 } catch (error) {
118 const message = error instanceof Error ? error.message : String(error)
119 const kind: GhErrorKind = /enoent|not found|cannot start|spawn/i.test(message) ? 'gh-missing' : /timeout|timed out|still running/i.test(message) ? 'timeout' : 'failed'
120 return { isOk: false, kind, exitCode: null, message, stdout: '' }
121 }
122}
123
124function firstLine(text: string): string {
125 const line = text.split('\n').find(part => part.trim() !== '')
126 return (line ?? 'gh failed').trim().slice(0, 300)
127}
128src/accounts.ts 120 lines1// Chooses which gh login reads a repository. Logins only ever reach runGh's
2// `login` option; `gh auth switch` is never run, so the global login is untouched.
3
4import type { AccountChoice, AccountFailure, RepoLocation } from './model'
5import { runGh } from './gh'
6import type { Exec } from './gh'
7
8const LIST_TIMEOUT_MS = 10_000
9const PROBE_QUERY = 'query($o:String!,$n:String!){repository(owner:$o,name:$n){id}}'
10
11/** Remembers the login that worked per owner; register.tsx backs it with $.store. */
12export type AccountMemory = {
13 get(owner: string): Promise<string | null>
14 set(owner: string, login: string): Promise<void>
15 forget(owner: string): Promise<void>
16}
17
18export type LoginList = { active: string | null; others: string[] }
19
20/** Parses `oborchers=oborchers, Org = other_login` into lowercased owner to login. */
21export function parseAccountConfig(text: string): Record<string, string> {
22 const config: Record<string, string> = {}
23 for (const part of text.split(/[,\n]/)) {
24 const at = part.indexOf('=')
25 if (at < 0) continue
26 const owner = part.slice(0, at).trim().toLowerCase()
27 const login = part.slice(at + 1).trim()
28 if (owner === '' || login === '' || /\s/.test(owner) || /\s/.test(login)) continue
29 config[owner] = login
30 }
31 return config
32}
33
34/** Lists the github.com logins gh knows; the active one first. */
35export async function listLogins(exec: Exec): Promise<LoginList | { kind: 'gh-missing' }> {
36 let ran
37 try {
38 ran = await exec(['gh', 'auth', 'status', '--json', 'hosts'], { timeoutMs: LIST_TIMEOUT_MS })
39 } catch {
40 return { kind: 'gh-missing' }
41 }
42 let parsed: unknown
43 try {
44 parsed = JSON.parse(ran.stdout)
45 } catch {
46 return ran.exitCode === 127 ? { kind: 'gh-missing' } : { active: null, others: [] }
47 }
48 const hosts = (parsed as { hosts?: Record<string, unknown> } | null)?.hosts
49 const entries = hosts?.['github.com']
50 let active: string | null = null
51 const others: string[] = []
52 if (Array.isArray(entries)) {
53 for (const entry of entries as { state?: unknown; active?: unknown; login?: unknown }[]) {
54 if (entry?.state !== 'success' || typeof entry.login !== 'string' || entry.login === '') continue
55 if (entry.active === true && active === null) active = entry.login
56 else if (!others.includes(entry.login)) others.push(entry.login)
57 }
58 }
59 return { active, others }
60}
61
62/** True when the login can see the repository. */
63export async function canRead(exec: Exec, login: string, owner: string, repo: string): Promise<boolean> {
64 const result = await runGh(
65 exec,
66 ['api', 'graphql', '-f', `query=${PROBE_QUERY}`, '-f', `o=${owner}`, '-f', `n=${repo}`],
67 { login },
68 )
69 if (!result.isOk) return false
70 try {
71 const data = JSON.parse(result.stdout) as { data?: { repository?: unknown } | null }
72 return data?.data?.repository != null
73 } catch {
74 return false
75 }
76}
77
78/** Picks the account: config, pinned, remembered, active, then probing the others. */
79export async function resolveAccount(
80 deps: { exec: Exec; memory: AccountMemory },
81 location: RepoLocation,
82 config: Record<string, string>,
83): Promise<AccountChoice | AccountFailure> {
84 const { exec, memory } = deps
85 const { owner, repo } = location
86 if (owner === null || repo === null) return { kind: 'no-access', tried: [] }
87
88 const configured = config[owner.toLowerCase()]
89 if (configured !== undefined) return { login: configured, source: 'config' }
90 if (location.pinnedLogin !== null) return { login: location.pinnedLogin, source: 'pinned' }
91
92 const tried: string[] = []
93 const probe = async (login: string): Promise<boolean> => {
94 tried.push(login)
95 return canRead(exec, login, owner, repo)
96 }
97
98 const remembered = await memory.get(owner)
99 if (remembered !== null) {
100 if (await probe(remembered)) return { login: remembered, source: 'remembered' }
101 await memory.forget(owner)
102 }
103
104 const logins = await listLogins(exec)
105 if ('kind' in logins) return { kind: 'gh-missing', tried }
106 if (logins.active === null && logins.others.length === 0) return { kind: 'not-logged-in', tried }
107
108 if (logins.active !== null && !tried.includes(logins.active) && (await probe(logins.active))) {
109 return { login: logins.active, source: 'active' }
110 }
111 for (const login of logins.others) {
112 if (tried.includes(login)) continue
113 if (await probe(login)) {
114 await memory.set(owner, login)
115 return { login, source: 'probed' }
116 }
117 }
118 return { kind: 'no-access', tried }
119}
120src/git.ts 74 lines1// Reads where the session's working directory points, as git sees it. Pure
2// parsers plus one injected-Exec reader; every git call runs in the given cwd,
3// so a linked worktree reports its own branch.
4
5import type { RepoLocation } from './model'
6import type { Exec } from './gh'
7import { OWNER_PATTERN, REPO_PATTERN } from './text'
8
9const GIT_TIMEOUT_MS = 5_000
10const URL_FORM = /^(?:https?|ssh|git):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i
11const SCP_FORM = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/\/)(.+)$/
12const PINNED_LOGIN = /gh\s+auth\s+token\s+(?:-u|--user)[\s=]+["']?([A-Za-z0-9][A-Za-z0-9_-]*)/
13
14/** Extracts owner and repository from a github.com remote URL; null for anything else, including names GitHub does not allow. */
15export function parseGitHubRemote(url: string): { owner: string; repo: string } | null {
16 const text = url.trim()
17 const match = URL_FORM.exec(text) ?? SCP_FORM.exec(text)
18 if (match === null) return null
19 const host = match[1]
20 const path = match[2]
21 if (host === undefined || path === undefined || host.toLowerCase() !== 'github.com') return null
22 const segments = path.replace(/\/+$/, '').replace(/\.git$/i, '').split('/')
23 const owner = segments[0]
24 const repo = segments[1]
25 if (segments.length !== 2 || !owner || !repo) return null
26 if (!OWNER_PATTERN.test(owner) || !REPO_PATTERN.test(repo)) return null
27 return { owner, repo }
28}
29
30/** Finds the login a repo-local `gh auth token -u <login>` credential helper pins; the last match wins. */
31export function parsePinnedLogin(helperLines: readonly string[]): string | null {
32 let login: string | null = null
33 for (const line of helperLines) {
34 const match = PINNED_LOGIN.exec(line)
35 const found = match?.[1]
36 if (found !== undefined) login = found
37 }
38 return login
39}
40
41/** Reads branch, GitHub remote and pinned login in cwd; null when cwd is not in a git work tree. */
42export async function readLocation(exec: Exec, cwd: string): Promise<RepoLocation | null> {
43 const inside = await git(exec, cwd, ['rev-parse', '--is-inside-work-tree'])
44 if (inside === null || inside.exitCode !== 0 || inside.stdout.trim() !== 'true') return null
45 const [branch, remote, helper, hostHelper] = await Promise.all([
46 git(exec, cwd, ['branch', '--show-current']),
47 git(exec, cwd, ['remote', 'get-url', 'origin']),
48 git(exec, cwd, ['config', '--local', '--get-all', 'credential.helper']),
49 git(exec, cwd, ['config', '--local', '--get-all', 'credential.https://github.com.helper']),
50 ])
51 const branchName = branch !== null && branch.exitCode === 0 ? branch.stdout.trim() : ''
52 const parsed = remote !== null && remote.exitCode === 0 ? parseGitHubRemote(remote.stdout) : null
53 return {
54 cwd,
55 branch: branchName === '' ? null : branchName,
56 owner: parsed?.owner ?? null,
57 repo: parsed?.repo ?? null,
58 pinnedLogin: parsePinnedLogin([...lines(helper), ...lines(hostHelper)]),
59 }
60}
61
62async function git(exec: Exec, cwd: string, args: readonly string[]) {
63 try {
64 return await exec(['git', '-C', cwd, ...args], { timeoutMs: GIT_TIMEOUT_MS })
65 } catch {
66 return null
67 }
68}
69
70function lines(ran: { exitCode: number | null; stdout: string } | null): string[] {
71 if (ran === null || ran.exitCode !== 0) return []
72 return ran.stdout.split('\n').filter(line => line.trim() !== '')
73}
74src/jobs.ts 73 lines1// Live Actions job detail, read with `gh run view <id> --json jobs`. Fetched
2// only while the pane is open and checks are running or failed.
3
4import { runGh } from './gh'
5import type { Exec } from './gh'
6import type { JobDetail, RepoLocation } from './model'
7import { checkStateFrom } from './query'
8import { cleanStr } from './text'
9
10const MAX_RUNS = 5
11
12type Obj = Record<string, unknown>
13
14function asObj(value: unknown): Obj | null {
15 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Obj) : null
16}
17
18function asStr(value: unknown): string | null {
19 return typeof value === 'string' ? value : null
20}
21
22function lowerStr(value: unknown): string {
23 return typeof value === 'string' ? value.toLowerCase() : ''
24}
25
26/** Reads `gh run view --json jobs` output into job details. Pure; never throws. */
27export function parseJobs(raw: unknown, runId: number): JobDetail[] {
28 const jobs = asObj(raw)?.jobs
29 if (!Array.isArray(jobs)) return []
30 const result: JobDetail[] = []
31 for (const entry of jobs) {
32 const job = asObj(entry)
33 const jobId = job?.databaseId
34 const name = cleanStr(job?.name)
35 if (job === null || typeof jobId !== 'number' || name === null) continue
36 const steps = Array.isArray(job.steps) ? job.steps.map(asObj).filter((step): step is Obj => step !== null) : []
37 const current = steps.find(step => lowerStr(step.status) === 'in_progress')
38 const failed = steps.find(step => lowerStr(step.conclusion) === 'failure')
39 result.push({
40 jobId,
41 runId,
42 name,
43 state: checkStateFrom(job.status, job.conclusion),
44 currentStep: cleanStr(current?.name),
45 failedStep: cleanStr(failed?.name),
46 url: asStr(job.url),
47 })
48 }
49 return result
50}
51
52function parseJson(text: string): unknown {
53 try {
54 return JSON.parse(text) as unknown
55 } catch {
56 return null
57 }
58}
59
60/** Fetches the jobs of up to five workflow runs in parallel; a run that fails is skipped. */
61export async function fetchJobs(exec: Exec, location: RepoLocation, login: string, runIds: number[]): Promise<JobDetail[]> {
62 if (location.owner === null || location.repo === null) return []
63 const repo = `${location.owner}/${location.repo}`
64 const unique = [...new Set(runIds)].slice(0, MAX_RUNS)
65 const perRun = await Promise.all(
66 unique.map(async runId => {
67 const ran = await runGh(exec, ['run', 'view', String(runId), '--json', 'jobs', '-R', repo], { login, cwd: location.cwd })
68 return ran.isOk ? parseJobs(parseJson(ran.stdout), runId) : []
69 }),
70 )
71 return perRun.flat()
72}
73src/issues.ts 394 lines1// The repository's open issues: GraphQL reads for the list and for one issue,
2// the pure normalizers, grouping and paging. fetchIssues and fetchIssue take the Exec.
3//
4// Cost, measured live: ISSUE_LIST_QUERY 3 points (100 nodes with labels,
5// assignees and linked pull requests), MENTIONED_QUERY 1, ISSUE_DETAIL_QUERY 1.
6
7import type { Exec } from './gh'
8import { LIST_LIMIT, PAGE_SIZE, ask, asNum, asObj, asStr, failed, fitPageSize, nodesOf, toRateLimit } from './list'
9import type { Failure } from './list'
10import type { AccountChoice, Issue, IssueComment, IssueGroup, IssueItem, IssueList, IssueSnapshot, LinkedPr, Problem, RateLimit, RepoLocation } from './model'
11import { SLUG_PATTERN, cleanStr } from './text'
12import { backoffMs, isTransient, reasonOf } from './scheduler'
13
14/** Group order, as drawn. */
15export const ISSUE_GROUPS: readonly IssueGroup[] = ['assigned', 'opened', 'mentioned', 'other']
16
17/** Open issues, newest first: variable $q (`repo:<owner>/<repo> is:issue is:open sort:created-desc`). */
18export const ISSUE_LIST_QUERY = `query($q: String!) {
19 search(query: $q, type: ISSUE, first: ${LIST_LIMIT}) {
20 issueCount
21 nodes {
22 ... on Issue {
23 id number title url createdAt
24 author { login }
25 labels(first: 10) { nodes { name color } }
26 assignees(first: 5) { nodes { login } }
27 comments { totalCount }
28 closedByPullRequestsReferences(first: 3) { nodes { number repository { nameWithOwner } } }
29 }
30 }
31 }
32 rateLimit { cost remaining resetAt }
33}`
34
35/** Numbers of the open issues that mention the viewer: variable $q (`... mentions:@me`). */
36export const MENTIONED_QUERY = `query($q: String!) {
37 search(query: $q, type: ISSUE, first: ${LIST_LIMIT}) {
38 issueCount
39 nodes { ... on Issue { number } }
40 }
41}`
42
43/** One issue by number: variables $owner, $name and $n (an Int, so sent with -F). */
44export const ISSUE_DETAIL_QUERY = `query($owner: String!, $name: String!, $n: Int!) {
45 repository(owner: $owner, name: $name) {
46 issue(number: $n) {
47 id number title url state stateReason createdAt
48 author { login }
49 labels(first: 20) { nodes { name color } }
50 assignees(first: 10) { nodes { login } }
51 milestone { title }
52 body
53 comments(last: 10) {
54 totalCount
55 nodes { author { login __typename } createdAt lastEditedAt body url }
56 }
57 closedByPullRequestsReferences(first: 5, includeClosedPrs: true) { nodes { number title state repository { nameWithOwner } } }
58 }
59 }
60 rateLimit { cost remaining resetAt }
61}`
62
63export type NormalizedIssues = {
64 items: IssueItem[]
65 total: number
66 isTruncated: boolean
67}
68
69export type IssueRow = { group: IssueGroup; item: IssueItem }
70
71export type IssuePage = {
72 rows: IssueRow[]
73 /** 1-based, clamped into 1..pageCount. */
74 page: number
75 pageCount: number
76 /** 1-based position of the first and last row on the page among all rows; 0 when empty. */
77 first: number
78 last: number
79 total: number
80}
81
82export type FetchedIssues =
83 | { kind: 'ok'; items: IssueItem[]; total: number; isTruncated: boolean; rateLimit: RateLimit | null }
84 | { kind: 'error'; failure: Failure }
85
86export type FetchedIssue =
87 | { kind: 'ok'; issue: Issue; rateLimit: RateLimit | null }
88 | { kind: 'error'; number: number; failure: Failure }
89
90function labelsOf(node: unknown): { name: string; color: string }[] {
91 const labels: { name: string; color: string }[] = []
92 for (const label of nodesOf(node)) {
93 const name = cleanStr(label.name)
94 if (name !== null) labels.push({ name, color: asStr(label.color) ?? '' })
95 }
96 return labels
97}
98
99function loginsOf(node: unknown): string[] {
100 const logins: string[] = []
101 for (const user of nodesOf(node)) {
102 const login = cleanStr(user.login)
103 if (login !== null) logins.push(login)
104 }
105 return logins
106}
107
108/** `owner/name` of a node's repository; null when absent or not a plain slug. */
109function repoOf(node: Record<string, unknown>): string | null {
110 const slug = asStr(asObj(node.repository)?.nameWithOwner)
111 return slug !== null && SLUG_PATTERN.test(slug) ? slug : null
112}
113
114function toItem(node: Record<string, unknown>, mentioned: ReadonlySet<number>, current: string | null): IssueItem | null {
115 const id = asStr(node.id)
116 const number = asNum(node.number)
117 if (id === null || number === null) return null
118 const linkedPrs: number[] = []
119 for (const pr of nodesOf(node.closedByPullRequestsReferences)) {
120 const linked = asNum(pr.number)
121 const repo = repoOf(pr)
122 if (linked === null) continue
123 // A pull request of another repository is not this issue's fix here.
124 if (current !== null && repo !== null && repo.toLowerCase() !== current.toLowerCase()) continue
125 linkedPrs.push(linked)
126 }
127 return {
128 id,
129 number,
130 title: cleanStr(node.title) ?? '',
131 url: asStr(node.url) ?? '',
132 author: cleanStr(asObj(node.author)?.login),
133 createdAt: asStr(node.createdAt) ?? '',
134 labels: labelsOf(node.labels),
135 assignees: loginsOf(node.assignees),
136 comments: asNum(asObj(node.comments)?.totalCount) ?? 0,
137 linkedPrs,
138 mentionsViewer: mentioned.has(number),
139 }
140}
141
142/**
143 * Turns ISSUE_LIST_QUERY's answer into list items. `mentionedNumbers` comes from
144 * MENTIONED_QUERY. `repository` is the one listed (`owner/name`): linked pull
145 * requests of any other repository are not counted. Pure; never throws;
146 * malformed issues are dropped.
147 */
148export function normalizeIssues(raw: unknown, mentionedNumbers: ReadonlySet<number>, repository: string | null = null): NormalizedIssues {
149 const search = asObj(asObj(asObj(raw)?.data)?.search)
150 const items: IssueItem[] = []
151 for (const node of nodesOf(search)) {
152 const item = toItem(node, mentionedNumbers, repository)
153 if (item !== null) items.push(item)
154 }
155 const total = Math.max(asNum(search?.issueCount) ?? 0, items.length)
156 return { items, total, isTruncated: total > LIST_LIMIT }
157}
158
159/** The issue numbers of MENTIONED_QUERY's answer. Pure; never throws. */
160export function mentionedNumbersOf(raw: unknown): Set<number> {
161 const numbers = new Set<number>()
162 for (const node of nodesOf(asObj(asObj(raw)?.data)?.search)) {
163 const number = asNum(node.number)
164 if (number !== null) numbers.add(number)
165 }
166 return numbers
167}
168
169function byNewest(a: IssueItem, b: IssueItem): number {
170 const left = Date.parse(a.createdAt)
171 const right = Date.parse(b.createdAt)
172 return (Number.isNaN(right) ? 0 : right) - (Number.isNaN(left) ? 0 : left)
173}
174
175/**
176 * Splits items into the four groups, each newest first; the first match wins.
177 * assigned = the viewer is an assignee; opened = authored by the viewer;
178 * mentioned = the viewer is mentioned; other = the rest. Pure, exported for tests.
179 */
180export function groupIssues(items: readonly IssueItem[], viewer: string): Record<IssueGroup, IssueItem[]> {
181 const me = viewer.toLowerCase()
182 const groups: Record<IssueGroup, IssueItem[]> = { assigned: [], opened: [], mentioned: [], other: [] }
183 for (const item of items) {
184 if (item.assignees.some(login => login.toLowerCase() === me)) groups.assigned.push(item)
185 else if (item.author !== null && item.author.toLowerCase() === me) groups.opened.push(item)
186 else if (item.mentionsViewer) groups.mentioned.push(item)
187 else groups.other.push(item)
188 }
189 for (const group of ISSUE_GROUPS) groups[group].sort(byNewest)
190 return groups
191}
192
193/** One page across the groups in order. `page` is 1-based and clamped. Pure, exported for tests. */
194export function paginateIssues(grouped: Record<IssueGroup, IssueItem[]>, page: number, pageSize: number = PAGE_SIZE): IssuePage {
195 const rows: IssueRow[] = []
196 for (const group of ISSUE_GROUPS) {
197 for (const item of grouped[group]) rows.push({ group, item })
198 }
199 const size = Math.max(1, Math.floor(pageSize))
200 const pageCount = Math.max(1, Math.ceil(rows.length / size))
201 const wanted = Number.isFinite(page) ? Math.floor(page) : 1
202 const clamped = Math.min(Math.max(wanted, 1), pageCount)
203 const start = (clamped - 1) * size
204 const slice = rows.slice(start, start + size)
205 return {
206 rows: slice,
207 page: clamped,
208 pageCount,
209 first: slice.length === 0 ? 0 : start + 1,
210 last: start + slice.length,
211 total: rows.length,
212 }
213}
214
215/** Terminal rows one issue takes: title line, meta line, blank line. */
216export const ROWS_PER_ISSUE = 3
217
218/**
219 * Rows the issue list needs for its current page: 3 per issue, minus the blank
220 * after the last one of each group, a heading per group, a blank before every
221 * heading but the first, and 4 for the header, the footer and slack. At most 40.
222 */
223export function issueRowCount(list: IssueList | null, page: number): number {
224 return Math.min(40, Math.max(6, issueDrawnRows(list, page, PAGE_SIZE)))
225}
226
227/** Rows the issue list view draws for the zero-based `page` of `pageSize` items, uncapped (0 without a list). */
228export function issueDrawnRows(list: IssueList | null, page: number, pageSize: number, isPadded = false): number {
229 if (list === null || list.kind !== 'list') return 0
230 const shown = paginateIssues(groupIssues(list.items, list.account.login), page + 1, pageSize)
231 const groups = new Set(shown.rows.map(row => row.group)).size
232 // Per group: its heading (+1) and no blank after its last issue (-1); between groups, one blank.
233 const padding = isPadded ? Math.max(0, Math.min(pageSize, shown.total) - shown.rows.length) : 0
234 return (shown.rows.length + padding) * ROWS_PER_ISSUE + Math.max(0, groups - 1) + 4
235}
236
237/** The page size that makes the issue list fit `bodyRows` when the page starts at item `firstIndex`. */
238export function issuePageSize(list: IssueList | null, firstIndex: number, bodyRows: number | undefined): number {
239 return fitPageSize(size => issueDrawnRows(list, Math.floor(firstIndex / size), size, true), bodyRows)
240}
241
242/** The highest zero-based page of the issue list. */
243export function lastIssuePage(list: IssueList | null, pageSize: number = PAGE_SIZE): number {
244 if (list === null || list.kind !== 'list') return 0
245 return paginateIssues(groupIssues(list.items, list.account.login), 1, pageSize).pageCount - 1
246}
247
248function issueSearch(location: RepoLocation, qualifiers: string): string | null {
249 if (location.owner === null || location.repo === null) return null
250 return `repo:${location.owner}/${location.repo} is:issue is:open ${qualifiers}`.trim()
251}
252
253/**
254 * Reads the repository's open issues (3 points) and which of them mention the
255 * viewer (1 point), both at once. `login` is the account the reads run as, and
256 * so the viewer that `@me` and the groups mean.
257 */
258export async function fetchIssues(exec: Exec, location: RepoLocation, login: string): Promise<FetchedIssues> {
259 const list = issueSearch(location, 'sort:created-desc')
260 const mentioned = issueSearch(location, 'mentions:@me')
261 if (list === null || mentioned === null) return { kind: 'error', failure: failed('no GitHub repository to list') }
262 const [listed, mentionedAsked] = await Promise.all([
263 ask(exec, ISSUE_LIST_QUERY, { q: list }, location, login),
264 ask(exec, MENTIONED_QUERY, { q: mentioned }, location, login),
265 ])
266 if ('failure' in listed) return { kind: 'error', failure: listed.failure }
267 if ('failure' in mentionedAsked) return { kind: 'error', failure: mentionedAsked.failure }
268 const normalized = normalizeIssues(listed.answer, mentionedNumbersOf(mentionedAsked.answer), `${location.owner}/${location.repo}`)
269 return { kind: 'ok', ...normalized, rateLimit: toRateLimit(asObj(asObj(listed.answer)?.data)?.rateLimit) }
270}
271
272/** The transient failure an issue list is retrying, if any. */
273export function issuesProblemOf(list: IssueList | null): Problem | null {
274 if (list === null || list.kind === 'loading') return null
275 return list.problem ?? null
276}
277
278/**
279 * The issue list after a read. A transient failure keeps the last list and
280 * marks it with a problem (15, 30, then 60 s backoff); with no list to keep it
281 * becomes an error that still carries the problem.
282 */
283export function issuesAfter(prev: IssueList | null, fetched: FetchedIssues, at: string, location: RepoLocation, account: AccountChoice): IssueList {
284 if (fetched.kind === 'ok') {
285 return { kind: 'list', at, location, account, items: fetched.items, total: fetched.total, isTruncated: fetched.isTruncated, problem: null }
286 }
287 const failure = fetched.failure
288 if (!isTransient(failure.kind)) return { kind: 'error', at, message: failure.kind === 'gh-missing' ? 'gh is not installed' : failure.message }
289 const earlier = issuesProblemOf(prev)
290 const problem: Problem = { reason: reasonOf(failure), since: earlier?.since ?? at, triedAt: at, retryInMs: backoffMs(earlier?.retryInMs ?? null) }
291 if (prev !== null && prev.kind === 'list') return { ...prev, problem }
292 return { kind: 'error', at, message: failure.message, problem }
293}
294
295/** GraphQL's SCREAMING_CASE enum as the model spells it: `NOT_PLANNED` becomes `not-planned`. */
296function lowered(value: unknown): string | null {
297 const text = asStr(value)
298 return text === null ? null : text.toLowerCase().replaceAll('_', '-')
299}
300
301const LINKED_STATES: Record<string, LinkedPr['state']> = { open: 'open', closed: 'closed', merged: 'merged' }
302
303function toComment(node: Record<string, unknown>): IssueComment {
304 const author = asObj(node.author)
305 const login = cleanStr(author?.login) ?? 'ghost'
306 return {
307 author: login,
308 isBot: author?.__typename === 'Bot' || login.endsWith('[bot]'),
309 createdAt: asStr(node.createdAt) ?? '',
310 editedAt: asStr(node.lastEditedAt),
311 body: cleanStr(node.body) ?? '',
312 url: asStr(node.url) ?? '',
313 }
314}
315
316/**
317 * Turns ISSUE_DETAIL_QUERY's answer into an Issue; null when the repository has
318 * no such issue or the node is malformed. A deleted comment author is `ghost`
319 * (GitHub's own name for it). Pure; never throws.
320 */
321export function normalizeIssue(raw: unknown): Issue | null {
322 const node = asObj(asObj(asObj(asObj(raw)?.data)?.repository)?.issue)
323 if (node === null) return null
324 const id = asStr(node.id)
325 const number = asNum(node.number)
326 if (id === null || number === null) return null
327 const linkedPrs: LinkedPr[] = []
328 for (const pr of nodesOf(node.closedByPullRequestsReferences)) {
329 const linked = asNum(pr.number)
330 if (linked === null) continue
331 const repo = repoOf(pr)
332 linkedPrs.push({
333 number: linked,
334 title: cleanStr(pr.title) ?? '',
335 state: LINKED_STATES[lowered(pr.state) ?? ''] ?? 'open',
336 ...(repo === null ? {} : { repo }),
337 })
338 }
339 const comments = asObj(node.comments)
340 const loaded = nodesOf(comments).map(toComment)
341 return {
342 id,
343 number,
344 title: cleanStr(node.title) ?? '',
345 url: asStr(node.url) ?? '',
346 state: lowered(node.state) === 'closed' ? 'closed' : 'open',
347 stateReason: lowered(node.stateReason),
348 author: cleanStr(asObj(node.author)?.login),
349 createdAt: asStr(node.createdAt) ?? '',
350 labels: labelsOf(node.labels),
351 assignees: loginsOf(node.assignees),
352 milestone: cleanStr(asObj(node.milestone)?.title),
353 body: cleanStr(node.body) ?? '',
354 linkedPrs,
355 comments: loaded,
356 commentCount: Math.max(asNum(comments?.totalCount) ?? 0, loaded.length),
357 }
358}
359
360/** Reads one issue by number (1 point). A number that is no issue (a pull request, or absent) is an error. */
361export async function fetchIssue(exec: Exec, location: RepoLocation, login: string, number: number): Promise<FetchedIssue> {
362 if (location.owner === null || location.repo === null) return { kind: 'error', number, failure: failed('no GitHub repository to read') }
363 const asked = await ask(exec, ISSUE_DETAIL_QUERY, { owner: location.owner, name: location.repo, n: number }, location, login)
364 if ('failure' in asked) return { kind: 'error', number, failure: asked.failure }
365 const issue = normalizeIssue(asked.answer)
366 if (issue === null) {
367 return { kind: 'error', number, failure: { ...failed(`#${number} is not an issue in ${location.owner}/${location.repo}`), kind: 'not-found' } }
368 }
369 return { kind: 'ok', issue, rateLimit: toRateLimit(asObj(asObj(asked.answer)?.data)?.rateLimit) }
370}
371
372/** The transient failure an issue snapshot is retrying, if any. */
373export function issueProblemOf(snapshot: IssueSnapshot | null): Problem | null {
374 if (snapshot === null || snapshot.kind === 'loading') return null
375 return snapshot.kind === 'issue' ? (snapshot.stale ?? null) : (snapshot.problem ?? null)
376}
377
378/**
379 * The issue snapshot after a read. A transient failure keeps the last good
380 * issue of the same number and marks it stale; with none to keep it becomes an
381 * error that still carries the problem.
382 */
383export function issueAfter(prev: IssueSnapshot | null, fetched: FetchedIssue, at: string, location: RepoLocation, account: AccountChoice): IssueSnapshot {
384 if (fetched.kind === 'ok') return { kind: 'issue', at, location, account, issue: fetched.issue, stale: null }
385 const failure = fetched.failure
386 const number = fetched.number
387 if (!isTransient(failure.kind)) return { kind: 'error', at, number, message: failure.kind === 'gh-missing' ? 'gh is not installed' : failure.message }
388 const kept = prev !== null && prev.kind === 'issue' && prev.issue.number === number ? prev : null
389 const earlier = kept === null ? null : issueProblemOf(kept)
390 const problem: Problem = { reason: reasonOf(failure), since: earlier?.since ?? at, triedAt: at, retryInMs: backoffMs(earlier?.retryInMs ?? null) }
391 if (kept !== null) return { ...kept, stale: problem }
392 return { kind: 'error', at, number, message: failure.message, problem }
393}
394src/list.ts 491 lines1// The repository's open pull requests as a list: GraphQL reads, the pure
2// normalizer, grouping and paging. fetchList and fetchCounts take the Exec.
3//
4// Cost, measured live against cli/cli: LIST_QUERY 3 points (100 nodes with
5// labels, review requests and check counts), REQUESTED_QUERY 1, COUNTS_QUERY 1.
6// `first: 0` is allowed on a search that only needs issueCount.
7
8import { runGh } from './gh'
9import type { Exec, GhResult } from './gh'
10import type { AccountChoice, ListChecks, ListCounts, ListGroup, ListItem, PrList, Problem, RateLimit, RepoLocation, ReviewDecision } from './model'
11import { backoffMs, isTransient, reasonOf } from './scheduler'
12import { cleanStr } from './text'
13
14/** Most pull requests one list read returns; a repository with more is truncated. */
15export const LIST_LIMIT = 100
16
17/** Rows per page of the list. */
18export const PAGE_SIZE = 10
19
20/** The list is read again this often while its pane is open. */
21export const LIST_POLL_MS = 60_000
22
23/** The band's counts are read again this often. */
24export const COUNTS_POLL_MS = 300_000
25
26/** A failed counts read is tried again after this long, never sooner. */
27export const COUNTS_RETRY_MS = 60_000
28
29/** Group order, as drawn. */
30export const GROUPS: readonly ListGroup[] = ['yours', 'requested', 'waiting', 'other']
31
32/** Open pull requests, newest first: variable $q (`repo:<owner>/<repo> is:pr is:open sort:created-desc`). */
33export const LIST_QUERY = `query($q: String!) {
34 search(query: $q, type: ISSUE, first: ${LIST_LIMIT}) {
35 issueCount
36 nodes {
37 ... on PullRequest {
38 id number title url isDraft createdAt
39 author { login }
40 labels(first: 10) { nodes { name color } }
41 reviewDecision
42 reviewRequests(first: 10) { nodes { requestedReviewer { __typename ... on User { login } ... on Team { slug } ... on Bot { login } } } }
43 comments { totalCount }
44 commits(last: 1) { nodes { commit { statusCheckRollup { state contexts { checkRunCountsByState { state count } statusContextCountsByState { state count } } } } } }
45 }
46 }
47 }
48 rateLimit { cost remaining resetAt }
49}`
50
51/** Numbers of the pull requests whose review is requested from the viewer, teams included: variable $q (`... review-requested:@me`). */
52export const REQUESTED_QUERY = `query($q: String!) {
53 search(query: $q, type: ISSUE, first: ${LIST_LIMIT}) {
54 issueCount
55 nodes { ... on PullRequest { number } }
56 }
57}`
58
59/** The numbers of the band: variables $yours (`... author:@me`), $requested (`... review-requested:@me`) and $issues (`repo:<owner>/<repo> is:issue is:open`). */
60export const COUNTS_QUERY = `query($open: String!, $yours: String!, $requested: String!, $issues: String!) {
61 open: search(query: $open, type: ISSUE, first: 0) { issueCount }
62 yours: search(query: $yours, type: ISSUE, first: 0) { issueCount }
63 requested: search(query: $requested, type: ISSUE, first: 0) { issueCount }
64 issues: search(query: $issues, type: ISSUE, first: 0) { issueCount }
65 rateLimit { cost remaining resetAt }
66}`
67
68export type NormalizedList = {
69 items: ListItem[]
70 total: number
71 isTruncated: boolean
72}
73
74export type ListRow = { group: ListGroup; item: ListItem }
75
76export type Page = {
77 rows: ListRow[]
78 /** 1-based, clamped into 1..pageCount. */
79 page: number
80 pageCount: number
81 /** 1-based position of the first and last row on the page among all rows; 0 when empty. */
82 first: number
83 last: number
84 /** Rows after the draft filter. */
85 total: number
86}
87
88export type Failure = Extract<GhResult, { isOk: false }>
89
90export type FetchedList =
91 | { kind: 'ok'; items: ListItem[]; total: number; isTruncated: boolean; rateLimit: RateLimit | null }
92 | { kind: 'error'; failure: Failure }
93
94export type FetchedCounts =
95 | { kind: 'ok'; counts: ListCounts; rateLimit: RateLimit | null }
96 | { kind: 'error'; failure: Failure }
97
98export type Obj = Record<string, unknown>
99
100export function asObj(value: unknown): Obj | null {
101 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Obj) : null
102}
103
104export function asStr(value: unknown): string | null {
105 return typeof value === 'string' ? value : null
106}
107
108export function asNum(value: unknown): number | null {
109 return typeof value === 'number' && Number.isFinite(value) ? value : null
110}
111
112export function nodesOf(value: unknown): Obj[] {
113 const nodes = asObj(value)?.nodes
114 if (!Array.isArray(nodes)) return []
115 const result: Obj[] = []
116 for (const node of nodes) {
117 const obj = asObj(node)
118 if (obj !== null) result.push(obj)
119 }
120 return result
121}
122
123const REVIEW_DECISIONS: Record<string, ReviewDecision> = {
124 APPROVED: 'approved',
125 CHANGES_REQUESTED: 'changes-requested',
126 REVIEW_REQUIRED: 'review-required',
127}
128
129/** Check run and status context states that count as passed: SUCCESS only. */
130const PASSED = new Set(['SUCCESS'])
131
132/** Failed: FAILURE, TIMED_OUT, CANCELLED, ACTION_REQUIRED, STARTUP_FAILURE and ERROR (a commit status). */
133const FAILED = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR'])
134
135/** Pending: QUEUED, IN_PROGRESS, PENDING, WAITING, REQUESTED and EXPECTED (a commit status). */
136const PENDING = new Set(['QUEUED', 'IN_PROGRESS', 'PENDING', 'WAITING', 'REQUESTED', 'EXPECTED'])
137
138/** Pending checks that have not started: everything pending except IN_PROGRESS. */
139const QUEUED = new Set(['QUEUED', 'PENDING', 'WAITING', 'REQUESTED', 'EXPECTED'])
140
141/**
142 * The counts of a statusCheckRollup. passed = SUCCESS; failed = FAILURE +
143 * TIMED_OUT + CANCELLED + ACTION_REQUIRED + STARTUP_FAILURE + ERROR; pending =
144 * QUEUED + IN_PROGRESS + PENDING + WAITING + REQUESTED + EXPECTED. Everything
145 * else (SKIPPED, NEUTRAL, STALE, COMPLETED) counts only toward total, so
146 * total = passed + failed + pending + skipped/neutral. Check runs and commit
147 * statuses are summed. Null when the commit has no rollup.
148 */
149export function checksFrom(rollup: unknown): ListChecks | null {
150 const contexts = asObj(asObj(rollup)?.contexts)
151 if (contexts === null) return null
152 const checks: ListChecks = { passed: 0, failed: 0, pending: 0, queued: 0, total: 0 }
153 for (const key of ['checkRunCountsByState', 'statusContextCountsByState']) {
154 const entries = contexts[key]
155 if (!Array.isArray(entries)) continue
156 for (const entry of entries) {
157 const obj = asObj(entry)
158 const count = asNum(obj?.count)
159 const state = asStr(obj?.state)?.toUpperCase() ?? ''
160 if (count === null || count <= 0) continue
161 checks.total += count
162 if (PASSED.has(state)) checks.passed += count
163 else if (FAILED.has(state)) checks.failed += count
164 else if (PENDING.has(state)) {
165 checks.pending += count
166 if (QUEUED.has(state)) checks.queued = (checks.queued ?? 0) + count
167 }
168 }
169 }
170 return checks
171}
172
173/**
174 * True when a review is requested from someone other than the viewer. A team
175 * is someone else, unless the viewer asked for it via requestedFromViewer; a
176 * request whose reviewer GitHub hid (null) is someone else too.
177 */
178function hasOtherRequests(node: Obj, viewer: string, requestedFromViewer: boolean): boolean {
179 const me = viewer.toLowerCase()
180 for (const request of nodesOf(node.reviewRequests)) {
181 const reviewer = asObj(request.requestedReviewer)
182 if (reviewer === null) return true
183 if (reviewer.__typename === 'Team') {
184 if (!requestedFromViewer) return true
185 continue
186 }
187 const login = asStr(reviewer.login)
188 if (login === null || login.toLowerCase() !== me) return true
189 }
190 return false
191}
192
193function toItem(node: Obj, requestedNumbers: ReadonlySet<number>, viewer: string): ListItem | null {
194 const id = asStr(node.id)
195 const number = asNum(node.number)
196 if (id === null || number === null) return null
197 const labels: { name: string; color: string }[] = []
198 for (const label of nodesOf(node.labels)) {
199 const name = cleanStr(label.name)
200 if (name !== null) labels.push({ name, color: asStr(label.color) ?? '' })
201 }
202 const rollup = asObj(nodesOf(node.commits)[0]?.commit)?.statusCheckRollup
203 const isRequested = requestedNumbers.has(number)
204 return {
205 id,
206 number,
207 title: cleanStr(node.title) ?? '',
208 url: asStr(node.url) ?? '',
209 isDraft: node.isDraft === true,
210 author: cleanStr(asObj(node.author)?.login),
211 createdAt: asStr(node.createdAt) ?? '',
212 labels,
213 reviewDecision: REVIEW_DECISIONS[asStr(node.reviewDecision) ?? ''] ?? null,
214 requestedFromViewer: isRequested,
215 hasOtherRequests: hasOtherRequests(node, viewer, isRequested),
216 checks: checksFrom(rollup),
217 comments: asNum(asObj(node.comments)?.totalCount) ?? 0,
218 }
219}
220
221/**
222 * Turns LIST_QUERY's answer into list items. `requestedNumbers` comes from
223 * REQUESTED_QUERY. Pure; never throws; malformed pull requests are dropped.
224 */
225export function normalizeList(raw: unknown, requestedNumbers: ReadonlySet<number>, viewer: string): NormalizedList {
226 const search = asObj(asObj(asObj(raw)?.data)?.search)
227 const items: ListItem[] = []
228 for (const node of nodesOf(search)) {
229 const item = toItem(node, requestedNumbers, viewer)
230 if (item !== null) items.push(item)
231 }
232 const total = Math.max(asNum(search?.issueCount) ?? 0, items.length)
233 return { items, total, isTruncated: total > LIST_LIMIT }
234}
235
236/** The pull request numbers of REQUESTED_QUERY's answer. Pure; never throws. */
237export function requestedNumbersOf(raw: unknown): Set<number> {
238 const numbers = new Set<number>()
239 for (const node of nodesOf(asObj(asObj(raw)?.data)?.search)) {
240 const number = asNum(node.number)
241 if (number !== null) numbers.add(number)
242 }
243 return numbers
244}
245
246function byNewest(a: ListItem, b: ListItem): number {
247 const left = Date.parse(a.createdAt)
248 const right = Date.parse(b.createdAt)
249 return (Number.isNaN(right) ? 0 : right) - (Number.isNaN(left) ? 0 : left)
250}
251
252/**
253 * Splits items into the four groups, each newest first. yours = authored by the
254 * viewer; requested = review requested from the viewer, not yours; waiting =
255 * review requested from someone else, in neither of those; other = the rest.
256 * Pure, exported for tests.
257 */
258export function groupItems(items: readonly ListItem[], viewer: string): Record<ListGroup, ListItem[]> {
259 const me = viewer.toLowerCase()
260 const groups: Record<ListGroup, ListItem[]> = { yours: [], requested: [], waiting: [], other: [] }
261 for (const item of items) {
262 if (item.author !== null && item.author.toLowerCase() === me) groups.yours.push(item)
263 else if (item.requestedFromViewer) groups.requested.push(item)
264 else if (item.hasOtherRequests) groups.waiting.push(item)
265 else groups.other.push(item)
266 }
267 for (const group of GROUPS) groups[group].sort(byNewest)
268 return groups
269}
270
271/**
272 * One page across the groups in order. Drafts are filtered before paging when
273 * hideDrafts is set. `page` is 1-based and clamped. Pure, exported for tests.
274 */
275export function paginate(grouped: Record<ListGroup, ListItem[]>, page: number, pageSize: number = PAGE_SIZE, hideDrafts: boolean): Page {
276 const rows: ListRow[] = []
277 for (const group of GROUPS) {
278 for (const item of grouped[group]) {
279 if (hideDrafts && item.isDraft) continue
280 rows.push({ group, item })
281 }
282 }
283 const size = Math.max(1, Math.floor(pageSize))
284 const pageCount = Math.max(1, Math.ceil(rows.length / size))
285 const wanted = Number.isFinite(page) ? Math.floor(page) : 1
286 const clamped = Math.min(Math.max(wanted, 1), pageCount)
287 const start = (clamped - 1) * size
288 const slice = rows.slice(start, start + size)
289 return {
290 rows: slice,
291 page: clamped,
292 pageCount,
293 first: slice.length === 0 ? 0 : start + 1,
294 last: start + slice.length,
295 total: rows.length,
296 }
297}
298
299export function toRateLimit(value: unknown): RateLimit | null {
300 const limit = asObj(value)
301 const remaining = asNum(limit?.remaining)
302 const resetAt = asStr(limit?.resetAt)
303 return remaining === null || resetAt === null ? null : { remaining, resetAt }
304}
305
306/** An answer that is not JSON (an HTML error page, a cut-off body) is GitHub's fault, so it is retried. */
307function unreadable(): Failure {
308 return { isOk: false, kind: 'server', exitCode: null, message: 'GitHub sent an unreadable answer', stdout: '' }
309}
310
311export function failed(message: string): Failure {
312 return { isOk: false, kind: 'failed', exitCode: null, message, stdout: '' }
313}
314
315export function parseJson(text: string): unknown {
316 try {
317 return JSON.parse(text) as unknown
318 } catch {
319 return null
320 }
321}
322
323/** The first error of a GraphQL answer that carries `errors` and no data to use. */
324function graphqlMessage(answer: unknown): string | null {
325 const errors = asObj(answer)?.errors
326 if (!Array.isArray(errors) || errors.length === 0 || asObj(asObj(answer)?.data) !== null) return null
327 return asStr(asObj(errors[0])?.message) ?? 'GraphQL error'
328}
329
330/** Runs one query and parses it: the answer, or the failure. */
331export async function ask(exec: Exec, query: string, variables: Record<string, string | number>, location: RepoLocation, login: string): Promise<{ answer: unknown } | { failure: Failure }> {
332 // -f, not -F: -F would turn a value like "123", "true" or "@file" into a number, bool or file read.
333 const args = ['api', 'graphql', '-f', `query=${query}`]
334 // A number is the one value -F is for: GraphQL wants an Int, and -f would send a string.
335 for (const [name, value] of Object.entries(variables)) args.push(typeof value === 'number' ? '-F' : '-f', `${name}=${value}`)
336 const ran = await runGh(exec, args, { login, cwd: location.cwd })
337 if (!ran.isOk) return { failure: ran }
338 const answer = parseJson(ran.stdout)
339 if (answer === null) return { failure: unreadable() }
340 const message = graphqlMessage(answer)
341 if (message !== null) return { failure: failed(message) }
342 return { answer }
343}
344
345function searchOf(location: RepoLocation, qualifiers: string): string | null {
346 if (location.owner === null || location.repo === null) return null
347 return `repo:${location.owner}/${location.repo} is:pr is:open ${qualifiers}`.trim()
348}
349
350/**
351 * Reads the repository's open pull requests (3 points) and which of them wait
352 * for the viewer's review (1 point), both at once. `login` is the account the
353 * reads run as, and so the viewer that `@me` and the groups mean.
354 */
355export async function fetchList(exec: Exec, location: RepoLocation, login: string): Promise<FetchedList> {
356 const list = searchOf(location, 'sort:created-desc')
357 const requested = searchOf(location, 'review-requested:@me')
358 if (list === null || requested === null) return { kind: 'error', failure: failed('no GitHub repository to list') }
359 const [listed, requestedAsked] = await Promise.all([
360 ask(exec, LIST_QUERY, { q: list }, location, login),
361 ask(exec, REQUESTED_QUERY, { q: requested }, location, login),
362 ])
363 if ('failure' in listed) return { kind: 'error', failure: listed.failure }
364 if ('failure' in requestedAsked) return { kind: 'error', failure: requestedAsked.failure }
365 const normalized = normalizeList(listed.answer, requestedNumbersOf(requestedAsked.answer), login)
366 return { kind: 'ok', ...normalized, rateLimit: toRateLimit(asObj(asObj(listed.answer)?.data)?.rateLimit) }
367}
368
369/** Reads the band's numbers for a branch without a pull request (1 point). */
370export async function fetchCounts(exec: Exec, location: RepoLocation, login: string, now: () => string = () => new Date().toISOString()): Promise<FetchedCounts> {
371 const open = searchOf(location, '')
372 const yours = searchOf(location, 'author:@me')
373 const requested = searchOf(location, 'review-requested:@me')
374 if (open === null || yours === null || requested === null || location.owner === null || location.repo === null) return { kind: 'error', failure: failed('no GitHub repository to count') }
375 const issues = `repo:${location.owner}/${location.repo} is:issue is:open`
376 const asked = await ask(exec, COUNTS_QUERY, { open, yours, requested, issues }, location, login)
377 if ('failure' in asked) return { kind: 'error', failure: asked.failure }
378 const data = asObj(asObj(asked.answer)?.data)
379 return {
380 kind: 'ok',
381 counts: {
382 open: asNum(asObj(data?.open)?.issueCount) ?? 0,
383 yours: asNum(asObj(data?.yours)?.issueCount) ?? 0,
384 requested: asNum(asObj(data?.requested)?.issueCount) ?? 0,
385 issues: asNum(asObj(data?.issues)?.issueCount) ?? 0,
386 at: now(),
387 },
388 rateLimit: toRateLimit(data?.rateLimit),
389 }
390}
391
392/** The transient failure a list is retrying, if any. */
393export function listProblemOf(list: PrList | null): Problem | null {
394 if (list === null || list.kind === 'loading') return null
395 return list.problem ?? null
396}
397
398/**
399 * The list after a read. A transient failure keeps the last list and marks it
400 * with a problem (15, 30, then 60 s backoff, as for the pull request); with no
401 * list to keep it becomes an error that still carries the problem.
402 */
403export function listAfter(prev: PrList | null, fetched: FetchedList, at: string, location: RepoLocation, account: AccountChoice): PrList {
404 if (fetched.kind === 'ok') {
405 return { kind: 'list', at, location, account, items: fetched.items, total: fetched.total, isTruncated: fetched.isTruncated, problem: null }
406 }
407 const failure = fetched.failure
408 if (!isTransient(failure.kind)) return { kind: 'error', at, message: failure.kind === 'gh-missing' ? 'gh is not installed' : failure.message }
409 const earlier = listProblemOf(prev)
410 const problem: Problem = { reason: reasonOf(failure), since: earlier?.since ?? at, triedAt: at, retryInMs: backoffMs(earlier?.retryInMs ?? null) }
411 if (prev !== null && prev.kind === 'list') return { ...prev, problem }
412 return { kind: 'error', at, message: failure.message, problem }
413}
414
415/** Below this many requests left, the list and the counts are read at most every LOW_RATE_POLL_MS. */
416export const LOW_RATE_REMAINING = 500
417export const LOW_RATE_POLL_MS = 300_000
418
419/** Milliseconds until the next read of the list; at least five minutes while the rate limit is low. */
420export function listDelayMs(list: PrList | null, rateLimit: RateLimit | null = null): number {
421 return pollDelayMs(listProblemOf(list)?.retryInMs ?? LIST_POLL_MS, rateLimit)
422}
423
424/** `delay`, raised to LOW_RATE_POLL_MS while fewer than LOW_RATE_REMAINING requests are left. */
425export function pollDelayMs(delay: number, rateLimit: RateLimit | null): number {
426 return rateLimit !== null && rateLimit.remaining < LOW_RATE_REMAINING ? Math.max(delay, LOW_RATE_POLL_MS) : delay
427}
428
429/** Terminal rows one pull request takes: title line, meta line, blank line. */
430export const ROWS_PER_PR = 3
431
432/**
433 * Rows the list pane needs for its current page: 3 per pull request, minus the
434 * blank after the last one of each group, a heading per group, a blank before
435 * every heading but the first, and 4 for the header, the footer and slack for a
436 * stale hint or a wrapped footer. At most 40.
437 */
438export function listRowCount(list: PrList | null, page: number, hideDrafts: boolean): number {
439 return Math.min(40, Math.max(6, listDrawnRows(list, page, hideDrafts, PAGE_SIZE)))
440}
441
442/**
443 * Rows the list view draws for the zero-based `page` of `pageSize` items, uncapped (0 without a
444 * list). With `isPadded` a short last page counts as full (as many items as fit the list), so
445 * the size a fit picks does not grow at the end of the list.
446 */
447export function listDrawnRows(list: PrList | null, page: number, hideDrafts: boolean, pageSize: number, isPadded = false): number {
448 if (list === null || list.kind !== 'list') return 0
449 const shown = paginate(groupItems(list.items, list.account.login), page + 1, pageSize, hideDrafts)
450 const groups = new Set(shown.rows.map(row => row.group)).size
451 // Per group: its heading (+1) and no blank after its last pull request (-1); between groups, one blank.
452 const padding = isPadded ? Math.max(0, Math.min(pageSize, shown.total) - shown.rows.length) : 0
453 return (shown.rows.length + padding) * ROWS_PER_PR + Math.max(0, groups - 1) + 4
454}
455
456/**
457 * The page size that makes the list fit `bodyRows` when the page starts at item
458 * `firstIndex` (zero-based): the largest up to PAGE_SIZE whose drawing fits.
459 */
460export function listPageSize(list: PrList | null, firstIndex: number, hideDrafts: boolean, bodyRows: number | undefined): number {
461 return fitPageSize(size => listDrawnRows(list, Math.floor(firstIndex / size), hideDrafts, size, true), bodyRows)
462}
463
464/** The highest zero-based page of the list under the current draft filter. */
465export function lastPage(list: PrList | null, hideDrafts: boolean, pageSize: number = PAGE_SIZE): number {
466 if (list === null || list.kind !== 'list') return 0
467 return paginate(groupItems(list.items, list.account.login), 1, pageSize, hideDrafts).pageCount - 1
468}
469
470/**
471 * The largest page size from `max` down to 1 whose drawn rows (`rowsFor(size)`)
472 * fit `bodyRows`; 1 when none does. Without a usable `bodyRows` (missing, 0,
473 * not finite: tests, desktop) it is `max`. Pure, exported for tests.
474 */
475export function fitPageSize(rowsFor: (size: number) => number, bodyRows: number | undefined, max: number = PAGE_SIZE): number {
476 if (bodyRows === undefined || !Number.isFinite(bodyRows) || bodyRows <= 0) return max
477 for (let size = max; size > 1; size--) {
478 if (rowsFor(size) <= bodyRows) return size
479 }
480 return 1
481}
482
483/**
484 * The zero-based page of `toSize` that holds the first item of page `page` of
485 * `fromSize`, so a pane that resizes keeps its first visible item. Pure, exported for tests.
486 */
487export function rebasePage(page: number, fromSize: number, toSize: number): number {
488 if (fromSize === toSize) return Math.max(0, page)
489 return Math.max(0, Math.floor((Math.max(0, page) * fromSize) / toSize))
490}
491src/model.ts 4 lines1// The data contract lives in types/index.d.ts (the $.state contract needs it
2// there); modules import their types from here.
3export type * from '../types';
4src/query.ts 580 lines1// The GraphQL read of the current branch's pull request, and the pure
2// normalizer that turns GitHub's answer into the PullRequest contract.
3//
4// Two small queries instead of one big one, 2 points of rate limit in total:
5// 1. PR_QUERY lists the branch's pull requests with a few scalars, so the
6// selection rule can pick one without paying for five full copies.
7// 2. PR_DETAIL_QUERY reads only the chosen pull request by node id. The id is
8// what makes `isRequired(pullRequestId: $id)` possible: that field needs the
9// pull request as an argument, which a list query cannot supply per node.
10
11import { runGh } from './gh'
12import type { Exec, GhResult } from './gh'
13import { activityAt } from './status'
14import { SLUG_PATTERN, cleanStr } from './text'
15import type {
16 Check,
17 CheckState,
18 CommentRef,
19 MergeMethod,
20 MergeQueueEntry,
21 MergeQueueState,
22 MergeStateStatus,
23 Mergeable,
24 PullRequest,
25 RateLimit,
26 RepoLocation,
27 Review,
28 ReviewDecision,
29 ReviewRequest,
30 ReviewState,
31 ReviewThread,
32} from './model'
33
34/** Lists the pull requests of a branch: variables $owner, $name, $branch. */
35export const PR_QUERY = `query($owner: String!, $name: String!, $branch: String!) {
36 repository(owner: $owner, name: $name) {
37 isPrivate
38 mergeCommitAllowed
39 squashMergeAllowed
40 rebaseMergeAllowed
41 pullRequests(headRefName: $branch, first: 5, orderBy: {field: UPDATED_AT, direction: DESC}, states: [OPEN, MERGED, CLOSED]) {
42 nodes { id number state updatedAt headRepositoryOwner { login } }
43 }
44 }
45 rateLimit { cost remaining resetAt }
46}`
47
48/** Resolves one pull request by number: variables $owner, $name, $number. Carries the repository scalars normalize needs. */
49export const PR_BY_NUMBER_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
50 repository(owner: $owner, name: $name) {
51 isPrivate
52 mergeCommitAllowed
53 squashMergeAllowed
54 rebaseMergeAllowed
55 pullRequest(number: $number) { id number state updatedAt headRepositoryOwner { login } }
56 }
57 rateLimit { cost remaining resetAt }
58}`
59
60/** The CheckRun and StatusContext selection, shared by the head commit and the merge group commit. */
61const CONTEXT_FIELDS = `__typename
62 ... on CheckRun { databaseId name status conclusion startedAt completedAt detailsUrl isRequired(pullRequestId: $id) checkSuite { workflowRun { databaseId workflow { name } } } }
63 ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestId: $id) }`
64
65/** Reads one pull request in full by node id: variable $id. */
66export const PR_DETAIL_QUERY = `query($id: ID!) {
67 node(id: $id) {
68 ... on PullRequest {
69 id number state title url isDraft author { login } mergedBy { login } mergedAt closedAt headRefName headRefOid
70 baseRefName mergeable mergeStateStatus reviewDecision
71 isInMergeQueue isMergeQueueEnabled
72 mergeQueueEntry { position state estimatedTimeToMerge headCommit { oid statusCheckRollup { state contexts(first: 100) { nodes {
73 ${CONTEXT_FIELDS}
74 } } } } }
75 autoMergeRequest { mergeMethod enabledAt }
76 viewerCanMergeAsAdmin
77 reviewRequests(first: 20) { nodes { requestedReviewer { __typename ... on User { login } ... on Team { slug organization { login } } ... on Bot { login } } } }
78 latestReviews(first: 20) { nodes { author { login } state submittedAt } }
79 reviewThreads(first: 100) { nodes { isResolved isOutdated path line comments(last: 5) { nodes { author { login } createdAt lastEditedAt url } } } }
80 comments(last: 100) { totalCount nodes { author { login } createdAt lastEditedAt url } }
81 closingIssuesReferences(first: 5) { nodes { number title state repository { nameWithOwner } } }
82 commits(last: 1) { nodes { commit { oid statusCheckRollup { state contexts(first: 100) { nodes {
83 ${CONTEXT_FIELDS}
84 } } } } } }
85 }
86 }
87 rateLimit { cost remaining resetAt }
88}`
89
90/** What normalize reads: the answers of PR_QUERY and PR_DETAIL_QUERY. */
91export type RawPullRequest = {
92 list: unknown
93 detail: unknown
94}
95
96export type NormalizedPullRequest = {
97 pr: PullRequest | null
98 rateLimit: RateLimit | null
99}
100
101export type FetchedPullRequest =
102 | { kind: 'ok'; pr: PullRequest | null; rateLimit: RateLimit | null }
103 | { kind: 'error'; failure: Extract<GhResult, { isOk: false }> }
104
105/** The candidate a branch lookup selected. */
106export type Candidate = {
107 id: string
108 number: number
109 state: string
110 ownerLogin: string | null
111}
112
113type Obj = Record<string, unknown>
114
115function asObj(value: unknown): Obj | null {
116 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Obj) : null
117}
118
119function asStr(value: unknown): string | null {
120 return typeof value === 'string' ? value : null
121}
122
123function asNum(value: unknown): number | null {
124 return typeof value === 'number' && Number.isFinite(value) ? value : null
125}
126
127function asBool(value: unknown): boolean {
128 return value === true
129}
130
131/** The `nodes` of a GraphQL connection, as objects only. */
132function nodesOf(value: unknown): Obj[] {
133 const nodes = asObj(value)?.nodes
134 if (!Array.isArray(nodes)) return []
135 const result: Obj[] = []
136 for (const node of nodes) {
137 const obj = asObj(node)
138 if (obj !== null) result.push(obj)
139 }
140 return result
141}
142
143function lower(value: unknown): string {
144 return typeof value === 'string' ? value.toLowerCase().replace(/_/g, '-') : ''
145}
146
147const MERGEABLE: Record<string, Mergeable> = {
148 MERGEABLE: 'mergeable',
149 CONFLICTING: 'conflicting',
150}
151
152const MERGE_STATES: readonly MergeStateStatus[] = ['clean', 'blocked', 'behind', 'dirty', 'draft', 'has-hooks', 'unknown', 'unstable']
153
154const REVIEW_DECISIONS: Record<string, ReviewDecision> = {
155 APPROVED: 'approved',
156 CHANGES_REQUESTED: 'changes-requested',
157 REVIEW_REQUIRED: 'review-required',
158}
159
160const REVIEW_STATES: readonly ReviewState[] = ['approved', 'changes-requested', 'commented', 'dismissed', 'pending']
161
162const QUEUE_STATES: readonly MergeQueueState[] = ['queued', 'awaiting-checks', 'mergeable', 'unmergeable', 'locked']
163
164const MERGE_METHODS: Record<string, MergeMethod> = {
165 MERGE: 'merge',
166 SQUASH: 'squash',
167 REBASE: 'rebase',
168}
169
170const PR_STATES: Record<string, PullRequest['state']> = {
171 OPEN: 'open',
172 MERGED: 'merged',
173 CLOSED: 'closed',
174}
175
176const CONCLUSIONS: Record<string, CheckState> = {
177 SUCCESS: 'success',
178 FAILURE: 'failure',
179 CANCELLED: 'cancelled',
180 SKIPPED: 'skipped',
181 NEUTRAL: 'neutral',
182 ACTION_REQUIRED: 'action-required',
183 TIMED_OUT: 'timed-out',
184 STALE: 'stale',
185 STARTUP_FAILURE: 'failure',
186}
187
188const STATUS_CONTEXT_STATES: Record<string, CheckState> = {
189 SUCCESS: 'success',
190 FAILURE: 'failure',
191 ERROR: 'failure',
192 PENDING: 'queued',
193 EXPECTED: 'queued',
194}
195
196/**
197 * Maps a check run's status and conclusion to a CheckState. Case-insensitive,
198 * so GraphQL (COMPLETED) and `gh run view` (completed) share it.
199 */
200export function checkStateFrom(status: unknown, conclusion: unknown): CheckState {
201 const statusKey = typeof status === 'string' ? status.toUpperCase() : ''
202 if (statusKey === 'IN_PROGRESS') return 'running'
203 if (statusKey === 'COMPLETED') {
204 const conclusionKey = typeof conclusion === 'string' ? conclusion.toUpperCase() : ''
205 return CONCLUSIONS[conclusionKey] ?? 'neutral'
206 }
207 return 'queued'
208}
209
210/** Maps a commit status context state to a CheckState. */
211export function statusContextState(state: unknown): CheckState {
212 const key = typeof state === 'string' ? state.toUpperCase() : ''
213 return STATUS_CONTEXT_STATES[key] ?? 'queued'
214}
215
216function toCheck(node: Obj): Check | null {
217 if (node.__typename === 'CheckRun') {
218 const name = cleanStr(node.name)
219 if (name === null) return null
220 const run = asObj(asObj(asObj(node.checkSuite)?.workflowRun))
221 return {
222 name,
223 workflow: cleanStr(asObj(run?.workflow)?.name),
224 state: checkStateFrom(node.status, node.conclusion),
225 isRequired: asBool(node.isRequired),
226 startedAt: asStr(node.startedAt),
227 completedAt: asStr(node.completedAt),
228 url: asStr(node.detailsUrl),
229 runId: asNum(run?.databaseId),
230 jobId: asNum(node.databaseId),
231 }
232 }
233 if (node.__typename === 'StatusContext') {
234 const name = cleanStr(node.context)
235 if (name === null) return null
236 const state = statusContextState(node.state)
237 return {
238 name,
239 workflow: null,
240 state,
241 isRequired: asBool(node.isRequired),
242 startedAt: asStr(node.createdAt),
243 completedAt: state === 'queued' ? null : asStr(node.createdAt),
244 url: asStr(node.targetUrl),
245 runId: null,
246 jobId: null,
247 }
248 }
249 return null
250}
251
252/** The checks of a statusCheckRollup, malformed contexts dropped, latest run per check kept. */
253function checksOf(rollup: unknown): Check[] {
254 const checks: Check[] = []
255 for (const context of nodesOf(asObj(rollup)?.contexts)) {
256 const check = toCheck(context)
257 if (check !== null) checks.push(check)
258 }
259 return latestPerCheck(checks)
260}
261
262/**
263 * A workflow that runs twice on one commit (two pull request events) reports
264 * every check twice. GitHub judges mergeability by the latest check run per
265 * workflow and name, so only that one is kept, at the position of the first.
266 * Check run ids grow, so the larger job id is the later run. Pure, exported for tests.
267 */
268export function latestPerCheck(checks: Check[]): Check[] {
269 const latest = new Map<string, Check>()
270 const order: string[] = []
271 for (const check of checks) {
272 if (check.jobId === null) {
273 order.push(`status:${order.length}`)
274 latest.set(order[order.length - 1] as string, check)
275 continue
276 }
277 const key = `${check.workflow ?? ''}\u0000${check.name}`
278 const known = latest.get(key)
279 if (known === undefined) {
280 order.push(key)
281 latest.set(key, check)
282 } else if ((known.jobId ?? 0) < check.jobId) {
283 latest.set(key, check)
284 }
285 }
286 return order.map((key) => latest.get(key) as Check)
287}
288
289/**
290 * Bots often post a comment and rewrite it moments later (automatic
291 * post-processing, 1 to 2 s after creation). Such an edit is part of posting,
292 * not an update, so an edit closer to creation than this is treated as no edit.
293 * Normalizing here keeps status, toasts and the pane unchanged.
294 */
295export const EDIT_GRACE_MS = 60_000
296
297function toCommentRef(node: Obj | undefined): CommentRef | null {
298 if (node === undefined) return null
299 const createdAt = asStr(node.createdAt)
300 const url = asStr(node.url)
301 if (createdAt === null || url === null) return null
302 const lastEditedAt = asStr(node.lastEditedAt)
303 const isEdited = lastEditedAt !== null && Date.parse(lastEditedAt) - Date.parse(createdAt) >= EDIT_GRACE_MS
304 return { author: cleanStr(asObj(node.author)?.login) ?? 'ghost', createdAt, editedAt: isEdited ? lastEditedAt : null, url }
305}
306
307/**
308 * The most recently active of a connection's comments (max of createdAt and
309 * lastEditedAt), so a review bot that edits an older comment in place
310 * surfaces. Ties go to the later node.
311 */
312function latestActive(nodes: Obj[]): CommentRef | null {
313 let best: CommentRef | null = null
314 for (const node of nodes) {
315 const ref = toCommentRef(node)
316 if (ref === null) continue
317 if (best === null || Date.parse(activityAt(ref)) >= Date.parse(activityAt(best))) best = ref
318 }
319 return best
320}
321
322function toReviewRequest(node: Obj): ReviewRequest | null {
323 const reviewer = asObj(node.requestedReviewer)
324 if (reviewer === null) return null
325 if (reviewer.__typename === 'Team') {
326 const slug = cleanStr(reviewer.slug)
327 if (slug === null) return null
328 const org = cleanStr(asObj(reviewer.organization)?.login)
329 return { reviewer: org === null ? slug : `${org}/${slug}`, isTeam: true }
330 }
331 const login = cleanStr(reviewer.login)
332 return login === null ? null : { reviewer: login, isTeam: false }
333}
334
335function toReview(node: Obj): Review {
336 const state = lower(node.state)
337 return {
338 author: cleanStr(asObj(node.author)?.login) ?? 'ghost',
339 state: REVIEW_STATES.find(known => known === state) ?? 'commented',
340 submittedAt: asStr(node.submittedAt),
341 }
342}
343
344function toThread(node: Obj): ReviewThread {
345 return {
346 isResolved: asBool(node.isResolved),
347 isOutdated: asBool(node.isOutdated),
348 path: asStr(node.path),
349 line: asNum(node.line),
350 last: latestActive(nodesOf(node.comments)),
351 }
352}
353
354function toQueueEntry(value: unknown): MergeQueueEntry | null {
355 const entry = asObj(value)
356 const position = asNum(entry?.position)
357 if (entry === null || position === null) return null
358 const state = lower(entry.state)
359 return {
360 position,
361 state: QUEUE_STATES.find(known => known === state) ?? 'queued',
362 estimatedTimeToMerge: asNum(entry.estimatedTimeToMerge),
363 checks: checksOf(asObj(entry.headCommit)?.statusCheckRollup),
364 }
365}
366
367function toRateLimit(value: unknown): RateLimit | null {
368 const limit = asObj(value)
369 const remaining = asNum(limit?.remaining)
370 const resetAt = asStr(limit?.resetAt)
371 return remaining === null || resetAt === null ? null : { remaining, resetAt }
372}
373
374/** The pull requests a branch lookup returned, malformed entries dropped. */
375export function candidatesOf(list: unknown): Candidate[] {
376 const repository = asObj(asObj(asObj(list)?.data)?.repository)
377 const result: Candidate[] = []
378 for (const node of nodesOf(asObj(repository?.pullRequests))) {
379 const id = asStr(node.id)
380 const number = asNum(node.number)
381 if (id === null || number === null) continue
382 result.push({ id, number, state: asStr(node.state) ?? '', ownerLogin: asStr(asObj(node.headRepositoryOwner)?.login) })
383 }
384 return result
385}
386
387/**
388 * The selection rule: the open pull request whose head repository belongs to
389 * the location's owner; else any open one; else the most recently updated
390 * merged or closed one (so the band can say "merged"). Lists arrive newest
391 * first. Pure, exported for tests.
392 */
393export function selectCandidate(candidates: readonly Candidate[], location: RepoLocation): Candidate | null {
394 const owner = location.owner?.toLowerCase() ?? null
395 const open = candidates.filter(candidate => candidate.state === 'OPEN')
396 const own = owner === null ? undefined : open.find(candidate => candidate.ownerLogin?.toLowerCase() === owner)
397 return own ?? open[0] ?? candidates.find(candidate => candidate.state === 'MERGED' || candidate.state === 'CLOSED') ?? null
398}
399
400function allowedMethods(repository: Obj | null): MergeMethod[] {
401 const methods: MergeMethod[] = []
402 if (repository?.mergeCommitAllowed === true) methods.push('merge')
403 if (repository?.squashMergeAllowed === true) methods.push('squash')
404 if (repository?.rebaseMergeAllowed === true) methods.push('rebase')
405 return methods
406}
407
408/** The issues a pull request closes when merged; entries without a number are dropped. */
409function linkedIssuesOf(raw: unknown): { number: number; title: string; state: 'open' | 'closed'; repo?: string }[] {
410 const issues: { number: number; title: string; state: 'open' | 'closed'; repo?: string }[] = []
411 for (const node of nodesOf(raw)) {
412 const number = asNum(node.number)
413 if (number === null) continue
414 const slug = asStr(asObj(node.repository)?.nameWithOwner)
415 const repo = slug !== null && SLUG_PATTERN.test(slug) ? slug : null
416 issues.push({
417 number,
418 title: cleanStr(node.title) ?? '',
419 state: lower(node.state) === 'closed' ? 'closed' : 'open',
420 ...(repo === null ? {} : { repo }),
421 })
422 }
423 return issues
424}
425
426/**
427 * Turns the two GraphQL answers into the contract. Pure; never throws.
428 * Anything unknown or missing falls back to a safe default.
429 */
430export function normalize(raw: unknown, location: RepoLocation): NormalizedPullRequest {
431 const input = asObj(raw)
432 const list = input?.list
433 const detail = input?.detail
434 const rateLimit = toRateLimit(asObj(asObj(detail)?.data)?.rateLimit) ?? toRateLimit(asObj(asObj(list)?.data)?.rateLimit)
435 const chosen = selectCandidate(candidatesOf(list), location)
436 const node = asObj(asObj(asObj(detail)?.data)?.node)
437 if (chosen === null || node === null) return { pr: null, rateLimit }
438 const id = asStr(node.id)
439 const number = asNum(node.number)
440 if (id === null || number === null) return { pr: null, rateLimit }
441
442 const repository = asObj(asObj(asObj(list)?.data)?.repository)
443 const commit = asObj(nodesOf(node.commits)[0]?.commit)
444 const rollup = asObj(commit?.statusCheckRollup)
445 const checks = checksOf(rollup)
446 const reviewRequests: ReviewRequest[] = []
447 for (const request of nodesOf(node.reviewRequests)) {
448 const parsed = toReviewRequest(request)
449 if (parsed !== null) reviewRequests.push(parsed)
450 }
451 const autoMerge = asObj(node.autoMergeRequest)
452 const autoMergeMethod = MERGE_METHODS[asStr(autoMerge?.mergeMethod) ?? '']
453 const mergeState = lower(node.mergeStateStatus)
454 const comments = asObj(node.comments)
455
456 const pr: PullRequest = {
457 id,
458 number,
459 state: PR_STATES[asStr(node.state) ?? ''] ?? PR_STATES[chosen.state] ?? 'open',
460 title: cleanStr(node.title) ?? '',
461 url: asStr(node.url) ?? '',
462 isPrivate: repository?.isPrivate === true,
463 isDraft: asBool(node.isDraft),
464 author: cleanStr(asObj(node.author)?.login),
465 mergedBy: cleanStr(asObj(node.mergedBy)?.login),
466 mergedAt: asStr(node.mergedAt),
467 closedAt: asStr(node.closedAt),
468 headRefName: cleanStr(node.headRefName) ?? location.branch ?? '',
469 headOid: asStr(node.headRefOid) ?? asStr(commit?.oid) ?? '',
470 baseRefName: asStr(node.baseRefName) ?? '',
471 mergeable: MERGEABLE[asStr(node.mergeable) ?? ''] ?? 'unknown',
472 mergeStateStatus: MERGE_STATES.find(known => known === mergeState) ?? 'unknown',
473 reviewDecision: REVIEW_DECISIONS[asStr(node.reviewDecision) ?? ''] ?? null,
474 isInMergeQueue: asBool(node.isInMergeQueue),
475 isMergeQueueEnabled: asBool(node.isMergeQueueEnabled),
476 mergeQueueEntry: toQueueEntry(node.mergeQueueEntry),
477 autoMerge: autoMerge !== null && autoMergeMethod !== undefined ? { method: autoMergeMethod, enabledAt: asStr(autoMerge.enabledAt) ?? '' } : null,
478 viewerCanMergeAsAdmin: asBool(node.viewerCanMergeAsAdmin),
479 allowedMethods: allowedMethods(repository),
480 checks,
481 reviews: nodesOf(node.latestReviews).map(toReview),
482 reviewRequests,
483 threads: nodesOf(node.reviewThreads).map(toThread),
484 commentCount: asNum(comments?.totalCount) ?? 0,
485 lastComment: latestActive(nodesOf(comments)),
486 linkedIssues: linkedIssuesOf(node.closingIssuesReferences),
487 }
488 return { pr, rateLimit }
489}
490
491function failed(message: string): Extract<GhResult, { isOk: false }> {
492 return { isOk: false, kind: 'failed', exitCode: null, message, stdout: '' }
493}
494
495/** An answer that is not JSON (an HTML error page, a cut-off body) is GitHub's fault, so it is retried. */
496function unreadable(): Extract<GhResult, { isOk: false }> {
497 return { isOk: false, kind: 'server', exitCode: null, message: 'GitHub sent an unreadable answer', stdout: '' }
498}
499
500/** Parses gh's stdout as JSON; null when it is not valid JSON. */
501function parseJson(text: string): unknown {
502 try {
503 return JSON.parse(text) as unknown
504 } catch {
505 return null
506 }
507}
508
509/** The first error of a GraphQL answer that carries `errors` and no data to use. */
510function graphqlMessage(answer: unknown): string | null {
511 const errors = asObj(answer)?.errors
512 if (!Array.isArray(errors) || errors.length === 0 || asObj(asObj(answer)?.data) !== null) return null
513 return asStr(asObj(errors[0])?.message) ?? 'GraphQL error'
514}
515
516/**
517 * Looks up the pull request of the location's branch: one list query, then one
518 * detail query for the selected pull request (2 points of rate limit).
519 */
520export async function fetchPullRequest(exec: Exec, location: RepoLocation, login: string): Promise<FetchedPullRequest> {
521 const { owner, repo, branch } = location
522 if (owner === null || repo === null || branch === null) {
523 return { kind: 'error', failure: failed('no GitHub repository or branch to look up') }
524 }
525 // -f, not -F: -F would turn a branch named "123", "true" or "@file" into a number, bool or file read.
526 const listRan = await runGh(exec, ['api', 'graphql', '-f', `query=${PR_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${repo}`, '-f', `branch=${branch}`], { login, cwd: location.cwd })
527 if (!listRan.isOk) return { kind: 'error', failure: listRan }
528 const list = parseJson(listRan.stdout)
529 if (list === null) return { kind: 'error', failure: unreadable() }
530 const listError = graphqlMessage(list)
531 if (listError !== null) return { kind: 'error', failure: failed(listError) }
532
533 return await readDetail(exec, location, login, list)
534}
535
536/** The second step of both lookups: pick the candidate of the list answer, read it in full, normalize. */
537async function readDetail(exec: Exec, location: RepoLocation, login: string, list: unknown): Promise<FetchedPullRequest> {
538 const chosen = selectCandidate(candidatesOf(list), location)
539 if (chosen === null) return { kind: 'ok', ...normalize({ list, detail: null }, location) }
540
541 const detailRan = await runGh(exec, ['api', 'graphql', '-f', `query=${PR_DETAIL_QUERY}`, '-f', `id=${chosen.id}`], { login, cwd: location.cwd })
542 if (!detailRan.isOk) return { kind: 'error', failure: detailRan }
543 const detail = parseJson(detailRan.stdout)
544 if (detail === null) return { kind: 'error', failure: unreadable() }
545 const detailError = graphqlMessage(detail)
546 if (detailError !== null) return { kind: 'error', failure: failed(detailError) }
547 return { kind: 'ok', ...normalize({ list, detail }, location) }
548}
549
550/** Reshapes the by-number answer like a branch list with one candidate, so normalize reads both alike. */
551function asBranchList(answer: unknown): unknown {
552 const data = asObj(asObj(answer)?.data)
553 const repository = asObj(data?.repository)
554 if (data === null || repository === null) return answer
555 const { pullRequest, ...scalars } = repository
556 const nodes = pullRequest === null || pullRequest === undefined ? [] : [pullRequest]
557 return { ...asObj(answer), data: { ...data, repository: { ...scalars, pullRequests: { nodes } } } }
558}
559
560/**
561 * Looks up one pull request of the location's repository by number, for the
562 * pull request picked from the list: an id query, then the same detail query
563 * and normalizer as the branch lookup (2 points of rate limit, the same as
564 * fetchPullRequest; the detail query needs the node id, so they cannot merge).
565 * A number that is no pull request of the repository gives `pr: null`.
566 */
567export async function fetchPullRequestByNumber(exec: Exec, location: RepoLocation, login: string, number: number): Promise<FetchedPullRequest> {
568 const { owner, repo } = location
569 if (owner === null || repo === null) return { kind: 'error', failure: failed('no GitHub repository to look up') }
570 if (!Number.isSafeInteger(number) || number < 1) return { kind: 'error', failure: failed(`${number} is not a pull request number`) }
571 // -F types the number as an Int; owner and name stay strings (-f).
572 const ran = await runGh(exec, ['api', 'graphql', '-f', `query=${PR_BY_NUMBER_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${repo}`, '-F', `number=${number}`], { login, cwd: location.cwd })
573 if (!ran.isOk) return { kind: 'error', failure: ran }
574 const answer = parseJson(ran.stdout)
575 if (answer === null) return { kind: 'error', failure: unreadable() }
576 const error = graphqlMessage(answer)
577 if (error !== null) return { kind: 'error', failure: failed(error) }
578 return await readDetail(exec, location, login, asBranchList(answer))
579}
580src/runs.ts 441 lines1// The Actions tab's data: workflow runs read over REST with `gh api`, the pure
2// normalizer, grouping and paging, and the main-branch status of the band.
3// fetchRuns, fetchMainStatus and fetchRun take the Exec.
4//
5// Cost: every read here is a REST call against the core limit (1 request each);
6// only fetchMainStatus is conditional, so a 304 costs nothing. fetchRuns reads
7// plainly: it merges three lists into one picture, the tab is open only while
8// someone looks at it, and RunList has no place to keep ETags between reads.
9
10import { parseIncludeOutput } from './etag'
11import { runGh } from './gh'
12import type { Exec, GhResult } from './gh'
13import { fetchJobs } from './jobs'
14import { PAGE_SIZE, fitPageSize, pollDelayMs } from './list'
15import type { AccountChoice, JobDetail, MainStatus, Problem, RateLimit, RepoLocation, RunGroup, RunItem, RunList, RunSnapshot } from './model'
16import { checkStateFrom } from './query'
17import { cleanStr } from './text'
18import { backoffMs, isTransient, reasonOf } from './scheduler'
19
20/** Runs read for the default branch and for this branch. */
21export const RUNS_PER_BRANCH = 50
22
23/** Runs read across all branches. */
24export const RUNS_RECENT_READ = 30
25
26/** Runs of other branches the tab lists. */
27export const RECENT_LIMIT = 20
28
29/** A run that is queued or running is read again this often. */
30export const RUN_POLL_MS = 15_000
31
32/** A finished run is read again this often. */
33export const RUN_IDLE_POLL_MS = 60_000
34
35/** The band's main-branch status is read again this often. */
36export const MAIN_STATUS_POLL_MS = 300_000
37
38/** Group order, as drawn. */
39export const RUN_GROUPS: readonly RunGroup[] = ['main', 'branch', 'recent']
40
41/** Terminal rows one run takes: title line, meta line, blank line. */
42export const ROWS_PER_RUN = 3
43
44type Failure = Extract<GhResult, { isOk: false }>
45
46export type FetchedRuns =
47 | { kind: 'ok'; defaultBranch: string; main: RunItem[]; branch: RunItem[]; recent: RunItem[] }
48 | { kind: 'error'; failure: Failure }
49
50export type FetchedMainStatus =
51 | { kind: 'ok'; status: MainStatus; etag: string | null }
52 | { kind: 'unchanged'; etag: string | null }
53 | { kind: 'error'; failure: Failure }
54
55export type FetchedRun =
56 | { kind: 'ok'; run: RunItem; jobs: JobDetail[] }
57 | { kind: 'error'; failure: Failure }
58
59export type RunRow = { group: RunGroup; item: RunItem }
60
61export type RunPage = {
62 rows: RunRow[]
63 /** 1-based, clamped into 1..pageCount. */
64 page: number
65 pageCount: number
66 /** 1-based position of the first and last row on the page among all rows; 0 when empty. */
67 first: number
68 last: number
69 total: number
70}
71
72type Obj = Record<string, unknown>
73
74function asObj(value: unknown): Obj | null {
75 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Obj) : null
76}
77
78function asStr(value: unknown): string | null {
79 return typeof value === 'string' ? value : null
80}
81
82function asNum(value: unknown): number | null {
83 return typeof value === 'number' && Number.isFinite(value) ? value : null
84}
85
86/**
87 * Reads one entry of `workflow_runs` into a run. status queued, waiting,
88 * requested or pending is 'queued', in_progress is 'running', completed is its
89 * conclusion (an unknown one is 'neutral'). Pure; never throws; an entry
90 * without id, workflow id, name or creation time is dropped.
91 */
92export function normalizeRun(raw: unknown): RunItem | null {
93 const run = asObj(raw)
94 const id = asNum(run?.id)
95 const workflowId = asNum(run?.workflow_id)
96 const name = cleanStr(run?.name)
97 const createdAt = asStr(run?.created_at)
98 if (run === null || id === null || workflowId === null || name === null || createdAt === null) return null
99 return {
100 id,
101 runNumber: asNum(run.run_number) ?? 0,
102 workflowId,
103 name,
104 branch: cleanStr(run.head_branch),
105 event: asStr(run.event) ?? '',
106 actor: cleanStr(asObj(run.actor)?.login),
107 state: checkStateFrom(run.status, run.conclusion),
108 headSha: asStr(run.head_sha) ?? '',
109 createdAt,
110 startedAt: asStr(run.run_started_at),
111 updatedAt: asStr(run.updated_at) ?? createdAt,
112 url: asStr(run.html_url) ?? '',
113 attempt: asNum(run.run_attempt) ?? 1,
114 }
115}
116
117/** The runs of a `workflow_runs` answer, malformed ones dropped. Pure; never throws. */
118export function runsOf(raw: unknown): RunItem[] {
119 const list = asObj(raw)?.workflow_runs
120 if (!Array.isArray(list)) return []
121 const runs: RunItem[] = []
122 for (const entry of list) {
123 const run = normalizeRun(entry)
124 if (run !== null) runs.push(run)
125 }
126 return runs
127}
128
129function time(text: string): number {
130 const ms = Date.parse(text)
131 return Number.isNaN(ms) ? 0 : ms
132}
133
134function byNewest(a: RunItem, b: RunItem): number {
135 return time(b.createdAt) - time(a.createdAt) || b.id - a.id
136}
137
138/** The newest run (by creation time, then id) of every workflow, ordered by workflow name. Pure. */
139export function newestPerWorkflow(runs: readonly RunItem[]): RunItem[] {
140 const newest = new Map<number, RunItem>()
141 for (const run of runs) {
142 const seen = newest.get(run.workflowId)
143 if (seen === undefined || byNewest(run, seen) < 0) newest.set(run.workflowId, run)
144 }
145 return [...newest.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workflowId - b.workflowId)
146}
147
148/** The newest `limit` runs that are on neither branch. A null branch excludes only the default one. Pure. */
149export function recentRuns(runs: readonly RunItem[], defaultBranch: string, branch: string | null, limit: number = RECENT_LIMIT): RunItem[] {
150 return runs
151 .filter(run => run.branch !== defaultBranch && (branch === null || run.branch !== branch))
152 .sort(byNewest)
153 .slice(0, limit)
154}
155
156function unreadable(): Failure {
157 return { isOk: false, kind: 'server', exitCode: null, message: 'GitHub sent an unreadable answer', stdout: '' }
158}
159
160function failed(message: string): Failure {
161 return { isOk: false, kind: 'failed', exitCode: null, message, stdout: '' }
162}
163
164function parseJson(text: string): unknown {
165 try {
166 return JSON.parse(text) as unknown
167 } catch {
168 return null
169 }
170}
171
172function repoPath(location: RepoLocation): string | null {
173 if (location.owner === null || location.repo === null) return null
174 return `repos/${location.owner}/${location.repo}`
175}
176
177function runsPath(base: string, perPage: number, branch: string | null): string {
178 return `${base}/actions/runs?${branch === null ? '' : `branch=${encodeURIComponent(branch)}&`}per_page=${perPage}`
179}
180
181/** Reads a JSON answer: the parsed body, or the failure. */
182async function getJson(exec: Exec, path: string, location: RepoLocation, login: string): Promise<{ answer: unknown } | { failure: Failure }> {
183 const ran = await runGh(exec, ['api', path], { login, cwd: location.cwd })
184 if (!ran.isOk) return { failure: ran }
185 const answer = parseJson(ran.stdout)
186 return answer === null ? { failure: unreadable() } : { answer }
187}
188
189/** The repository's default branch: one REST call that prints only the name. */
190async function readDefaultBranch(exec: Exec, base: string, location: RepoLocation, login: string): Promise<{ branch: string } | { failure: Failure }> {
191 const ran = await runGh(exec, ['api', base, '--jq', '.default_branch'], { login, cwd: location.cwd })
192 if (!ran.isOk) return { failure: ran }
193 const branch = ran.stdout.trim()
194 return branch === '' ? { failure: unreadable() } : { branch }
195}
196
197/**
198 * Reads the Actions runs of the tab: the default branch (50), this branch (50,
199 * skipped on the default branch or a detached HEAD) and all branches (30), as
200 * plain REST calls in parallel. `knownDefault` is the default branch of an
201 * earlier read, which saves the call that finds it. main and branch hold the
202 * newest run per workflow; recent the 20 newest runs on neither.
203 */
204export async function fetchRuns(exec: Exec, location: RepoLocation, login: string, knownDefault: string | null = null): Promise<FetchedRuns> {
205 const base = repoPath(location)
206 if (base === null) return { kind: 'error', failure: failed('no GitHub repository to list runs of') }
207 let defaultBranch = knownDefault
208 if (defaultBranch === null) {
209 const found = await readDefaultBranch(exec, base, location, login)
210 if ('failure' in found) return { kind: 'error', failure: found.failure }
211 defaultBranch = found.branch
212 }
213 const ownBranch = location.branch !== null && location.branch !== defaultBranch ? location.branch : null
214 const [onMain, onBranch, onAny] = await Promise.all([
215 getJson(exec, runsPath(base, RUNS_PER_BRANCH, defaultBranch), location, login),
216 ownBranch === null ? Promise.resolve({ answer: null }) : getJson(exec, runsPath(base, RUNS_PER_BRANCH, ownBranch), location, login),
217 getJson(exec, runsPath(base, RUNS_RECENT_READ, null), location, login),
218 ])
219 if ('failure' in onMain) return { kind: 'error', failure: onMain.failure }
220 if ('failure' in onBranch) return { kind: 'error', failure: onBranch.failure }
221 if ('failure' in onAny) return { kind: 'error', failure: onAny.failure }
222 return {
223 kind: 'ok',
224 defaultBranch,
225 main: newestPerWorkflow(runsOf(onMain.answer)),
226 branch: newestPerWorkflow(runsOf(onBranch.answer)),
227 recent: recentRuns(runsOf(onAny.answer), defaultBranch, location.branch),
228 }
229}
230
231/** The transient failure a run list is retrying, if any. */
232export function runsProblemOf(list: RunList | null): Problem | null {
233 if (list === null || list.kind === 'loading') return null
234 return list.problem ?? null
235}
236
237/** A transient failure as a problem: reason, first failure of the streak, and the next backoff step. */
238function problemFor(failure: Failure, earlier: Problem | null, at: string): Problem {
239 return { reason: reasonOf(failure), since: earlier?.since ?? at, triedAt: at, retryInMs: backoffMs(earlier?.retryInMs ?? null) }
240}
241
242/**
243 * The run list after a read. A transient failure keeps the last list and marks
244 * it with a problem (15, 30, then 60 s backoff); with no list to keep it
245 * becomes an error that still carries the problem.
246 */
247export function runsAfter(prev: RunList | null, fetched: FetchedRuns, at: string, location: RepoLocation, account: AccountChoice): RunList {
248 if (fetched.kind === 'ok') {
249 return { kind: 'list', at, location, account, defaultBranch: fetched.defaultBranch, main: fetched.main, branch: fetched.branch, recent: fetched.recent, problem: null }
250 }
251 const failure = fetched.failure
252 if (!isTransient(failure.kind)) return { kind: 'error', at, message: failure.kind === 'gh-missing' ? 'gh is not installed' : failure.message }
253 const problem = problemFor(failure, runsProblemOf(prev), at)
254 if (prev !== null && prev.kind === 'list') return { ...prev, problem }
255 return { kind: 'error', at, message: failure.message, problem }
256}
257
258/**
259 * One page across the groups in order main, branch, recent. `page` is 1-based
260 * and clamped. Pure, exported for tests.
261 */
262export function paginateRuns(groups: Record<RunGroup, readonly RunItem[]>, page: number, pageSize: number = PAGE_SIZE): RunPage {
263 const rows: RunRow[] = []
264 for (const group of RUN_GROUPS) {
265 for (const item of groups[group]) rows.push({ group, item })
266 }
267 const size = Math.max(1, Math.floor(pageSize))
268 const pageCount = Math.max(1, Math.ceil(rows.length / size))
269 const wanted = Number.isFinite(page) ? Math.floor(page) : 1
270 const clamped = Math.min(Math.max(wanted, 1), pageCount)
271 const start = (clamped - 1) * size
272 const slice = rows.slice(start, start + size)
273 return {
274 rows: slice,
275 page: clamped,
276 pageCount,
277 first: slice.length === 0 ? 0 : start + 1,
278 last: start + slice.length,
279 total: rows.length,
280 }
281}
282
283/**
284 * Rows the Actions pane needs for its current page (zero-based `page`): 3 per
285 * run, a heading per group, a blank before every heading but the first, and 4
286 * for the header, the footer and slack for a stale hint or a wrapped footer.
287 * At least 6, at most 40.
288 */
289export function runRowCount(list: RunList | null, page: number): number {
290 return Math.min(40, Math.max(6, runDrawnRows(list, page, PAGE_SIZE)))
291}
292
293/** Rows the Actions list view draws for the zero-based `page` of `pageSize` items, uncapped (0 without a list). */
294export function runDrawnRows(list: RunList | null, page: number, pageSize: number, isPadded = false): number {
295 if (list === null || list.kind !== 'list') return 0
296 const shown = paginateRuns(list, page + 1, pageSize)
297 const groups = new Set(shown.rows.map(row => row.group)).size
298 // Per group: its heading (+1) and no blank after its last run (-1); between groups, one blank.
299 const padding = isPadded ? Math.max(0, Math.min(pageSize, shown.total) - shown.rows.length) : 0
300 return (shown.rows.length + padding) * ROWS_PER_RUN + Math.max(0, groups - 1) + 4
301}
302
303/** The page size that makes the Actions list fit `bodyRows` when the page starts at item `firstIndex`. */
304export function runPageSize(list: RunList | null, firstIndex: number, bodyRows: number | undefined): number {
305 return fitPageSize(size => runDrawnRows(list, Math.floor(firstIndex / size), size, true), bodyRows)
306}
307
308/** The highest zero-based page of the run list. */
309export function lastRunPage(list: RunList | null, pageSize: number = PAGE_SIZE): number {
310 if (list === null || list.kind !== 'list') return 0
311 return paginateRuns(list, 1, pageSize).pageCount - 1
312}
313
314/** States that make a workflow count as failing on the default branch. Cancelled does not. */
315const FAILING = new Set(['failure', 'timed-out'])
316
317/**
318 * The status of the default branch from its runs: the newest run per workflow
319 * decides. worst = failure (failure and timed-out) over running over queued
320 * over success; anything else (cancelled, skipped, neutral, ...) is 'neutral';
321 * null without runs. failing lists those workflows, newest first. Pure.
322 */
323export function mainStatusOf(runs: readonly RunItem[], defaultBranch: string, at: string): MainStatus {
324 const newest = newestPerWorkflow(runs)
325 const failing = newest
326 .filter(run => FAILING.has(run.state))
327 .sort(byNewest)
328 .map(run => ({ name: run.name, runId: run.id, url: run.url }))
329 const states = new Set(newest.map(run => run.state))
330 let worst: MainStatus['worst'] = null
331 if (newest.length > 0) {
332 if (failing.length > 0) worst = 'failure'
333 else if (states.has('running')) worst = 'running'
334 else if (states.has('queued')) worst = 'queued'
335 else if (states.has('success')) worst = 'success'
336 else worst = 'neutral'
337 }
338 return { at, defaultBranch, worst, failing }
339}
340
341/**
342 * Reads the default branch's runs (50) with a conditional GET. A 304 costs no
343 * rate limit and returns 'unchanged'. `etag` is the one an earlier call
344 * returned; pass `knownDefault` (an earlier status's defaultBranch) so that no
345 * extra call finds the default branch.
346 */
347export async function fetchMainStatus(exec: Exec, location: RepoLocation, login: string, etag: string | null = null, knownDefault: string | null = null, now: () => string = () => new Date().toISOString()): Promise<FetchedMainStatus> {
348 const base = repoPath(location)
349 if (base === null) return { kind: 'error', failure: failed('no GitHub repository to read runs of') }
350 let defaultBranch = knownDefault
351 if (defaultBranch === null) {
352 const found = await readDefaultBranch(exec, base, location, login)
353 if ('failure' in found) return { kind: 'error', failure: found.failure }
354 defaultBranch = found.branch
355 }
356 const path = runsPath(base, RUNS_PER_BRANCH, defaultBranch)
357 const args = etag === null ? ['api', '-i', path] : ['api', '-i', '-H', `If-None-Match: ${etag}`, path]
358 const ran = await runGh(exec, args, { login, cwd: location.cwd })
359 const parsed = parseIncludeOutput(ran.stdout)
360 // A 304 makes gh exit 1, so it is told from stdout, as in etag.ts.
361 if (parsed.status === 304) return { kind: 'unchanged', etag }
362 if (!ran.isOk) return { kind: 'error', failure: ran }
363 const answer = parseJson(parsed.body)
364 if (parsed.status !== 200 || answer === null) return { kind: 'error', failure: unreadable() }
365 return { kind: 'ok', status: mainStatusOf(runsOf(answer), defaultBranch, now()), etag: parsed.etag ?? etag }
366}
367
368/** Reads one run and its jobs. A run that cannot be read as a run is an unreadable answer. */
369export async function fetchRun(exec: Exec, location: RepoLocation, login: string, runId: number): Promise<FetchedRun> {
370 const base = repoPath(location)
371 if (base === null) return { kind: 'error', failure: failed('no GitHub repository to read the run of') }
372 const [asked, jobs] = await Promise.all([
373 getJson(exec, `${base}/actions/runs/${runId}`, location, login),
374 fetchJobs(exec, location, login, [runId]),
375 ])
376 if ('failure' in asked) return { kind: 'error', failure: asked.failure }
377 const run = normalizeRun(asked.answer)
378 return run === null ? { kind: 'error', failure: unreadable() } : { kind: 'ok', run, jobs }
379}
380
381/** The transient failure a run snapshot is retrying, if any. */
382export function runProblemOf(snapshot: RunSnapshot | null): Problem | null {
383 if (snapshot === null || snapshot.kind === 'loading') return null
384 return snapshot.kind === 'run' ? (snapshot.stale ?? null) : (snapshot.problem ?? null)
385}
386
387/**
388 * The run snapshot after a read. A 404 is an error naming the run; a
389 * transient failure keeps the last snapshot of this run, marked stale, with the
390 * same backoff as the list.
391 */
392export function runAfter(prev: RunSnapshot | null, fetched: FetchedRun, at: string, location: RepoLocation, account: AccountChoice, runId: number): RunSnapshot {
393 if (fetched.kind === 'ok') return { kind: 'run', at, location, account, run: fetched.run, jobs: fetched.jobs, stale: null }
394 const failure = fetched.failure
395 if (failure.kind === 'not-found') return { kind: 'error', at, runId, message: `run ${runId} not found in ${location.owner ?? ''}/${location.repo ?? ''}` }
396 if (!isTransient(failure.kind)) return { kind: 'error', at, runId, message: failure.kind === 'gh-missing' ? 'gh is not installed' : failure.message }
397 const problem = problemFor(failure, runProblemOf(prev), at)
398 if (prev !== null && prev.kind === 'run' && prev.run.id === runId) return { ...prev, stale: problem }
399 return { kind: 'error', at, runId, message: failure.message, problem }
400}
401
402/** Milliseconds until the next read of the run: the backoff while failing, 15 s while it is queued or running, else 60 s. */
403export function runDelayMs(snapshot: RunSnapshot | null, rateLimit: RateLimit | null = null): number {
404 const problem = runProblemOf(snapshot)
405 if (problem !== null) return pollDelayMs(problem.retryInMs, rateLimit)
406 const isLive = snapshot !== null && snapshot.kind === 'run' && (snapshot.run.state === 'queued' || snapshot.run.state === 'running')
407 return pollDelayMs(isLive ? RUN_POLL_MS : RUN_IDLE_POLL_MS, rateLimit)
408}
409
410const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
411
412/** Job states that count as failed in a run reference. */
413const FAILED_JOB_STATES: readonly string[] = ['failure', 'timed-out']
414
415/**
416 * The text `[f]` in a run's detail puts into the prompt: which workflow failed where
417 * and how to read it, never the log itself. Null unless the snapshot is a run that
418 * failed or has a failed job. Pure.
419 */
420export function runReference(snapshot: RunSnapshot): string | null {
421 if (snapshot.kind !== 'run') return null
422 const { run, jobs, location } = snapshot
423 const failed = jobs.filter(job => FAILED_JOB_STATES.includes(job.state))
424 if (!FAILED_JOB_STATES.includes(run.state) && failed.length === 0) return null
425 const slug = location.owner !== null && location.repo !== null ? `${location.owner}/${location.repo}` : ''
426 const lines = [`CI failed in workflow "${oneLine(run.name)}" on ${slug} (run ${run.id}, ${run.url})`]
427 if (run.branch !== null) lines.push(`Branch: ${oneLine(run.branch)}`)
428 if (run.headSha !== '') lines.push(`Commit: ${run.headSha}`)
429 if (failed.length > 0) {
430 lines.push('Failed jobs:')
431 for (const job of failed) {
432 const parts = [`${oneLine(job.name)}: ${job.state}`, `job ${job.jobId}`]
433 if (job.failedStep) parts.push(`failed step "${oneLine(job.failedStep)}"`)
434 lines.push(`- ${parts.join(', ')}`)
435 }
436 }
437 lines.push(` gh run view ${run.id} --log-failed${slug === '' ? '' : ` -R ${slug}`}`)
438 lines.push('Please investigate this failure and fix it.')
439 return lines.join('\n')
440}
441