SLOPSHOPPER

gh-status

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

newpanebandguardcommandtoast
★ 5v0.3.1MITupdated 2026-10-13oborchers/claude-gh-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gh-status
│ ┃ PR ✕ › fix the failing auth test and add an audit log call │ ┃ 1: PRs 2: Issues 3: Actions │ ┃ This directory is not a git repository. ⏺ Read(src/auth.ts) │ ┃ r: refresh ⎿ Read 6 lines │ ┃ l: all PRs ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pr │ ⎿ gh-status: PR pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PR
1: PRs 2: Issues 3: Actions This directory is not a git repository. r: refresh l: all PRs
README

gh-status

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%">

What you see

  • A band above the prompt. One line with the PR number, state badge, verdict, pipeline, reviews, unresolved threads and the account in use.
  • A detail pane with /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).
  • A live pipeline. Every job with its running step, updated while checks run.
  • An honest verdict. ready to merge · 1 check failed is red, it is yellow while checks run, and it is green only when everything is clean.
  • Reviews and unresolved threads. Who approved, who asked for changes, and what is still open.
  • Merge queue. Your position in the queue and the queue's own checks.
  • All open pull requests. A grouped list of the repository's open pull requests, and any of them in the same pane.
  • Reviews from the terminal. Approve, request changes, or mark your own draft ready for review, each after a confirmation.
  • Issues and Actions tabs. The same pane has a tab row, 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 branch status. When a workflow fails on the default branch, the band shows main ✗ <workflow>.
  • The right account per repository. Personal and work repositories each use their own gh login, with no gh auth switch.

Tabs and navigation

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).

  • Each tab keeps its place. Level (list or detail), page and row are remembered per tab, so switching away and back lands where you left.
  • Enter opens. Enter or a click on a row opens it in the same tab.
  • 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.
  • Cross links jump to the other tab. The linked issues of a pull request, the linked pull requests of an issue, and a run opened from a pull request's pipeline each open in the tab they belong to. b there goes to that tab's list.
  • List pages fit the pane. The page size follows the height of the pane, so Up and Down move item by item. n and p page.
  • Locked during a confirmation. While a confirmation waits, the tab row is drawn as plain text and cannot be used, so the focus stays on the confirmation.

What opens where

Entry pointOpens
/gh with a pull request on the branchThe PRs tab, showing that pull request
/gh without oneThe 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 lsThe 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 bandThe PRs tab, showing the list
main ✗ notice in the bandThe Actions tab (hotkey 3 while the band has the keyboard)

Compact view

<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.

Merge without leaving the terminal

<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.

  • Confirm press. Every write needs it, inline in the band and in the pane. Presses in the first 600 ms after a confirmation appears are ignored.
  • Bound to the head commit you saw. Merges use --match-head-commit, queue and auto-merge use expectedHeadOid, so GitHub refuses if the branch moved.
  • Fresh re-check. On confirm, the pull request is fetched again in full and compared: same repository, same PR, same account, same head commit, action still available. A mismatch does nothing and asks you to review again.
  • No plain merge with a merge queue. Use add to queue, or admin merge.
  • Admin merge in two steps. Step 1 lists the rules it bypasses, step 2 repeats them with a separate final key. If the rules changed in between, you go back to step 1.
  • Unknown results are not retried. After a timeout or network error the confirmation is cleared and the status is refreshed.
  • Review actions follow the same rules. Confirm press, the 600 ms guard, the fresh re-check, and the head commit as commitOID. A review whose result is unknown (a timeout or a server error) is never retried.
  • Merge is never offered in the list. Picking a pull request only shows it in the pane; writes happen there.
  • No tool for the model. Claude cannot merge through gh-status.
  • --delete-branch and --auto are never used.

The band

<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):

KeyDoes
dOpen the detail pane
oOpen the pull request on GitHub
fFix CI with Claude (when a check failed)
mMerge
qAdd to the merge queue
lOpen the list of open pull requests (when the branch has no pull request)
3Open the Actions tab (when the main ✗ notice is shown)
y / nConfirm / cancel a pending confirmation

All open pull requests

<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.

  • Groups, in this order. Yours, Review requested from you, Waiting for review, Other open.
  • Two lines per pull request. The first has the status symbol (✓ 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.
  • One stop per pull request. The arrow keys move one pull request at a time. The title of the focused one is shown in the terminal's own selection highlight (the mouse pointer does the same under it), while the status symbol, the labels and the facts keep their colors. Enter or a click on the title opens it.
  • Pages that fit the pane. n next, p previous.
  • Drafts. Draft titles are dim. h hides or shows them.
  • Live. The list is read again every 60 s while the pane shows it, and r refreshes now.

Open any pull request

<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.

Review a pull request

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.

  • Enter does not post. Enter in the text field only keeps the text. Leave the field with Esc, then press y to post or n to cancel.
  • Bound to the head commit you saw. If the branch moved, GitHub refuses the review and you are asked to look again.
  • Not offered on your own pull requests (GitHub does not allow it), nor on drafts.

Issues

<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.

  • Groups, in this order. Assigned to you, Opened by you, Mentioning you, Other open.
  • Two lines per issue. The first has the title and the labels in a muted version of their GitHub color. The second has the number, author, age, assignees, comment count and linked pull requests.
  • Pages. 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:

  • The state, title, author and age, the labels, the assignees and the milestone.
  • The linked pull requests. Picking one opens it in the PRs tab. Pull requests of another repository are shown, not opened.
  • The description, rendered as Markdown and cut after a number of lines; o opens the issue on GitHub for the rest.
  • The latest comments, each cut the same way, with edits marked.

o opens the issue on GitHub, c copies its URL and r refreshes. Issues are read only: gh-status does not write to them.

Actions

<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.

  • Groups, in this order. The default branch (its name is the heading; the newest run of each workflow), This branch, Recent.
  • Two lines per run. The first has the state symbol, the workflow name and the branch. The second has the run number, event, actor, age and duration.
  • Pages. 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.

Install

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:

  • Claude Code 2.1.290 or later
  • The GitHub CLI gh, logged in (gh auth login)
  • git

Commands and keys

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.

CommandDoes
/ghShow 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 refreshRefresh now
/gh openOpen the pull request on GitHub
/gh copyCopy the pull request URL
/gh closeClose 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 backUp one level in the active tab
/gh approveAsk to approve (optional comment)
/gh changesAsk to request changes (text required)
/gh readyAsk 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 fixPut 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):

WhereKeys
Bandl 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 tabs1 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 listsup and down move between items, Enter or a click opens one, n and p page, r refresh
Issue detailo open on GitHub, c copy URL, r refresh
Run detailo open on GitHub, c copy URL, r refresh, t retry failed jobs and f fix CI (for a failed run)
PR listup 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 panem 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.

Accounts

The account for a repository is chosen in this order:

  1. The accounts setting for the repository owner.
  2. A login pinned by a repo-local credential.helper that calls gh auth token -u <login>.
  3. The login remembered from an earlier successful run for this owner.
  4. The active gh login, if it can read the repository.
  5. The other logged-in 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.

Settings

Shown as rows in /config.

SettingDefaultMeaning
Account per owner (accounts)emptyComma separated owner=login pairs, e.g. my-org=work-login,my-user=my-user. Wins over automatic detection.
Fast interval (fastIntervalSeconds)5Check interval while checks run or the PR is in the merge queue.
Idle interval (idleIntervalSeconds)30Check interval when nothing runs.
Toasts on state changes (toasts)onShow a toast when the pipeline, reviews, comments or mergeability change.
Command name (command)prSlash 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)redred shows main ✗ <workflow> in the band while a workflow of the default branch fails; off hides it and stops reading it.
Pane width (paneWidthPercent)35Share 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)claudeclaude follows your Claude Code theme; github uses GitHub's Primer colors.

Rate limits

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.

Live updates and rate limits

  • While checks run or the PR is in the merge queue, gh-status checks every 5 s (the fast interval); otherwise every 30 s (the idle interval). With no open PR it checks every 120 s.
  • The check is a set of conditional REST requests (ETag). A 304 Not Modified answer does not count against the REST rate limit.
  • When something changed, or at least every 60 s, it runs a full GraphQL refresh, which costs 2 points.
  • When fewer than 500 points remain, polling slows to at most once a minute.
  • It also refreshes immediately after 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.
  • Job details for the pane are fetched only while the pane is open.
  • While the pane shows the list it is read every 60 s (about 3 points per read). The counts in the band are read every 5 minutes (1 point). Both slow to at most once every 5 minutes when fewer than 500 points remain.
  • The Issues and Actions tabs and their details are read only while the tab is shown. The main ✗ status is read every 5 minutes with a conditional request.
  • A short toast announces changes in the pipeline, reviews, comments or mergeability (setting toasts).

Limits

  • GitHub does not push events, so the status is polled. Changes show up within the check interval.
  • Nothing is drawn in VS Code's chat panel, in -p runs or in cloud sessions.
  • Branch protection details need admin rights on the repository.
  • At most 100 checks per commit are read.
  • Only github.com remotes are supported.

Development

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.
  • The state contract lives in types/index.d.ts.
  • Tests are in tests/.

License

MIT, see LICENSE.

Source 31 files
hooks/register.tsx 1619 lines
1import { 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 lines
1// 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}
453
src/etag.ts 102 lines
1// 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}
102
src/gh.ts 128 lines
1// 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}
128
src/accounts.ts 120 lines
1// 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}
120
src/git.ts 74 lines
1// 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}
74
src/jobs.ts 73 lines
1// 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}
73
src/issues.ts 394 lines
1// 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}
394
src/list.ts 491 lines
1// 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}
491
src/model.ts 4 lines
1// 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';
4
src/query.ts 580 lines
1// 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}
580
src/runs.ts 441 lines
1// 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