PR panel for Claude Code: your pull requests, review requests and merged pull requests, with whose Turn it is

Formerly PR Master, renamed by referee decision. 🟨
A Claude Code mod that shows your pull requests, the ones your teams must review and the ones you merged, from opening to production, and tells you whose Turn it is on each. You can act on them, or ask Claude to analyse them, without leaving the session.
gh auth login); the mod runs gh auth token, orgh auth token gives none. It needs the repo and read:org scopes.open <url>).At the prompt of a Claude Code terminal session, type:
/plugin install otro-nombre --marketplace escorponox/otro-nombre
Then:
y to add the github:escorponox/otro-nombre marketplace.Claude Code answers Installed otro-nombre. Plugin is now active. and the mod runs in that session straight away.
To get a newer version later:
claude plugin update otro-nombre@otro-nombre
then run /reload-plugins in an open session, or start a new one.
/prs opens the PR panel, or closes it when it is open. With the panel closed, the status line still sums up what waits on you, for example ● 1 ready · 2 need you · 3 to review.
The panel has three tabs:
| Tab | Key | What it lists |
|---|---|---|
| Mine | 1 | Your open pull requests, with their Readiness, from Draft to Ready to merge |
| Review | 2 | Open, non-draft pull requests by others where one of your teams is asked to review and you have not reviewed yet. Team switches under the tabs, one team a row, show or hide each team's requests, or turn on its Auto-approval |
| Merged | 3 | Your pull requests merged within the merged window, followed through CI, release, staging and production |
Other keys in the panel:
r refreshes now. The panel refreshes by itself every minute while open, and every five minutes while closed.l shows or hides the legend of colours.Each pull request carries a coloured dot for its Turn: Mine (you act next), Theirs (CI, reviewers or the author), Done (nothing left to do) or Parked (a draft).
While the session is open, a toast tells you when one of your pull requests:
#123 api · Auto-approved (only the session that gave it raises it).Each toast says the pull request's number, its repository and what it became, for example #123 api · Changes requested, and stays ten seconds. More than three at once come as one toast, for example 5 Alerts · /prs. Clicking a toast only closes it: open /prs to act on the pull request.
The first time a session shows your pull requests it raises none for what already waits; from then on it raises one for each change, those since your sessions last fetched included. Every open session raises its own.
Move the focus to a pull request with Tab, the arrow keys or a click. The focused pull request's actions get hotkeys:
| Action | Key | Where |
|---|---|---|
| Copy link | c | Every tab |
| Squash merge | m | Mine, when Ready to merge |
| Update branch | u | Mine, when the branch is behind its base |
| Close | x | Mine |
| Approve | p | Review |
| Unblock analysis | n | Mine |
| Review analysis | n | Review |
Pressing the pull request's number opens it in the browser.
Merge, update, close and approve write to GitHub, so each asks for confirmation: the first press arms the button, and a second press within three seconds runs it.
Approve leaves a sarcastic comment on the approval, picked at random. The sarcasm aims at the reviewer, the process and CI, never at the author.
A team switch in the Review tab has three states: [ ] off, [x] on and [A] on with Auto-approval. A press on [ ] switches the team on. A press on [x] asks auto?: a second press within three seconds turns it to [A], and none switches the team off. A press on [A] switches the team off. The switches are shared by all your sessions.
With [A], the mod approves for you, with the same sarcastic comments as Approve, every Review request asked of that team (of at least one, when it is asked of several of yours) once:
[A]: a request made, or a commit pushed, since then. What was listed at that moment is left to you.A new commit that dismisses the approval gets a new one, on the same conditions. Each commit is approved at most once, across all your sessions; one GitHub refuses shows on the pull request and is not tried again until the next commit. Auto-approvals happen only while a Claude Code session of yours is open.
An analysis runs Claude in the background, with read-only tools, on one pull request:
The result shows under the pull request and is kept for that commit. It stays out of the conversation: the subagent's report, which Claude Code would hand to the session, is dropped, leaving one line that says the analysis is in the panel. Continue in conversation sends it to the session as a prompt, so you can carry on from it with Claude.
The Merged tab follows each pull request through CI, its release, staging and production. Staging and production come from the stableVersion that Kargo commits into the organisation's deployments repository, read through the GitHub API. They show only for repositories that publish GitHub Releases, declare their image as repository: in .circleci/config.yml and have a deployments repository in the same organisation with a kargo/ folder; other repositories show CI and release alone. The token must be able to read the deployments repository and use code search.
Set them on install, or later with claude plugin configure otro-nombre@otro-nombre, which also shows the ones left unset.
| Setting | Values | Default | What it does |
|---|---|---|---|
| Stale threshold | off, 2w, 1m, 3m, 6m | 1m | Marks pull requests older than this as stale |
| Review window | off, 1w, 2w, 1m | 2w | Lists Review requests opened within this window |
| Merged window | off, 1d, 3d, 1w | 3d | Lists your pull requests merged within this window in the Merged tab |
| Hidden organisations | logins | none | Organisations or users whose pull requests the panel never shows |
| Show private repositories | yes / no | yes | Shows pull requests in private repositories |
| Alerts | yes / no | yes | Raises a toast when a pull request needs you, becomes Ready to merge, reaches production or fails CI after merging |
| GitHub token | token | none | Used only when gh auth token gives none |
Run a session with the mod loaded from this working copy; saving a file reloads it:
claude --plugin-dir ./otro-nombre
Check and test it:
claude plugin validate .
claude plugin test ./otro-nombre
Domain terms are defined in GLOSSARY.md.
hooks/register.tsx 15 lines1import type { Register } from 'claude-code'
2
3import { registerActions } from './actions'
4import { registerAnalyses } from './analyses'
5import { readOptions } from './options'
6import { registerPane } from './pane'
7import { registerRefresh } from './refresh'
8
9export const register: Register = (on, options) => {
10 registerRefresh(on, readOptions(options))
11 registerPane(on)
12 registerActions(on)
13 registerAnalyses(on)
14}
15hooks/actions.ts 64 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, UiPressArgument } from 'claude-code'
3
4import type { MergedPullRequestItem, PullRequestItem } from '../types'
5import { splitKey } from './domain/element-key'
6import { messageOf, refOf } from './domain/format'
7import { EMPTY_SNAPSHOT, findItem } from './snapshot'
8
9const OPEN_TIMEOUT_MS = 10_000
10
11/** A pull request of any list: an open one or a merged one. */
12type ListedItem = PullRequestItem | MergedPullRequestItem
13
14// The engine's scan only accepts atoms declared in the file that reads and writes them.
15const snapshot = atom({ plugin: 'otro-nombre', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
16/** Drawn on the refresh line by `pane.tsx`. */
17const actionProblem = atom({ plugin: 'otro-nombre', key: 'actionProblem' } as const, null as string | null)
18
19/**
20 * The pull request actions. The pane draws each action's Button keyed `<kind>:<item id>` (see
21 * `items.tsx`) with a closure that does nothing; this hook answers the press, since every `$` call
22 * must live in the file holding its hook. Any press on the pane clears the last action's failure.
23 *
24 * To add an action: add it to `ACTIONS` in `items.tsx` and a `case` for its kind below.
25 */
26export function registerActions(on: On) {
27 on('ui.press', { plugin: 'otro-nombre' }, async ($, e, next) => {
28 await update($, actionProblem, () => null)
29 const [kind, id] = splitKey(e.element)
30 const item = findItem(await read($, snapshot), id)
31 if (item) {
32 switch (kind) {
33 case 'pr':
34 await openInBrowser($, item)
35 break
36 case 'copy':
37 await copyLink($, e, item)
38 break
39 }
40 }
41 return next(e)
42 })
43}
44
45/** Runs `open <url>`; a failure shows on the refresh line. */
46async function openInBrowser($: EngineInterface, item: ListedItem) {
47 try {
48 const run = await $.process.run(['open', item.url], { timeoutMs: OPEN_TIMEOUT_MS })
49 if (run.exitCode !== 0) await fail($, `Couldn't open ${refOf(item)} (${run.stderr.trim() || `exit ${run.exitCode}`})`)
50 } catch (error) {
51 await fail($, `Couldn't open ${refOf(item)} (${messageOf(error)})`)
52 }
53}
54
55/** Copies the pull request's URL on the surface the press came from. */
56async function copyLink($: EngineInterface, press: UiPressArgument, item: ListedItem) {
57 const copied = await $.ui.copy({ text: item.url, surface: press.surface })
58 if (!copied.isCopied) await fail($, `Couldn't copy the link of ${refOf(item)} (${copied.reason})`)
59}
60
61async function fail($: EngineInterface, problem: string) {
62 await update($, actionProblem, () => problem)
63}
64hooks/analyses.ts 147 lines1import { atom, read, update } from 'claude-code'
2import type { AgentSpawnResult, EngineInterface, On, TurnCompleteInput } from 'claude-code'
3
4import type { Analysis, AnalysisKind, PullRequestItem, RunningAnalysis } from '../types'
5import { ANALYSIS_NAME, analysisKey, analysisOfAction, analysisPrompt, analysisRef, continuePrompt, handedBackAnalysis, latestAnalyses } from './domain/analysis'
6import { splitKey } from './domain/element-key'
7import { messageOf, refOf } from './domain/format'
8import { EMPTY_SNAPSHOT, findOpenItem } from './snapshot'
9
10/** The subagent type every analysis runs on: read-only tools, since an analysis changes nothing. */
11const SUBAGENT_TYPE = 'Explore'
12
13/** The key prefix of the Button that carries an analysis into the conversation. */
14const CONTINUE = 'continue'
15
16// The engine's scan only accepts atoms declared in the file that reads and writes them.
17const snapshot = atom({ plugin: 'otro-nombre', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
18/** Drawn on the refresh line by `pane.tsx`. */
19const actionProblem = atom({ plugin: 'otro-nombre', key: 'actionProblem' } as const, null as string | null)
20const analyses = atom({ plugin: 'otro-nombre', key: 'analyses' } as const, {} as Record<string, Analysis>)
21const analysing = atom({ plugin: 'otro-nombre', key: 'analysing' } as const, {} as Record<string, RunningAnalysis>)
22const handBacks = atom({ plugin: 'otro-nombre', key: 'handBacks' } as const, {} as Record<string, string>)
23
24/**
25 * The analyses: a press on an analysis action (see `ANALYSIS_ACTION`) starts a background subagent
26 * with that analysis's prompt, one at a time per pull request; the subagent's `turn.complete`,
27 * matched by its `agentId`, brings the result, kept in `$.store` under `owner/repo#number@headSha`
28 * and shown under the pull request. Opening the panel reads the kept results; "Continue in
29 * conversation" submits one as a prompt. The running analyses live in `$.state`, so a module
30 * reload still matches their answers. The engine also hands each subagent's report to the
31 * conversation, which would start a turn on it: that delivery is dropped, the panel showing it.
32 */
33export function registerAnalyses(on: On) {
34 // Work after `next`: actions.ts clears the refresh line's problem on every press.
35 on('ui.press', { plugin: 'otro-nombre' }, async ($, e, next) => {
36 const pressed = await next(e)
37 const [action, id] = splitKey(e.element)
38 const kind = analysisOfAction(action)
39 if (kind === undefined && action !== CONTINUE) return pressed
40 const item = findOpenItem(await read($, snapshot), id)
41 if (!item) return pressed
42 // The subagent may take a while to start: the press does not wait for it.
43 if (kind !== undefined) void analyse($, kind, item)
44 else await continueInConversation($, item)
45 return pressed
46 })
47
48 on('turn.complete', async ($, e, next) => {
49 if (e.agentId !== undefined) await finish($, e.agentId, e)
50 return next(e)
51 })
52
53 on('ui.open', { id: 'otro-nombre' }, async ($, e, next) => {
54 const opened = await next(e)
55 await readKeptAnalyses($)
56 return opened
57 })
58
59 // A hook that fails is skipped, so the hand-back then reaches the conversation: nothing is lost.
60 on('prompt.submit', async ($, e, next) => {
61 const handedBack = handedBackAnalysis(e.origin, e.text, await read($, handBacks))
62 if (!handedBack) return next(e)
63 await update($, handBacks, ({ [handedBack.agentId]: _, ...others }) => others)
64 return { drop: `The ${handedBack.name} is in the PR panel` }
65 })
66}
67
68/**
69 * Starts a `kind` analysis of `item`'s head commit, unless one of the pull request already runs.
70 * A spawn that is refused, fails or hands back no `agentId` (so its answer could never be matched)
71 * leaves nothing running and says why on the refresh line.
72 */
73async function analyse($: EngineInterface, kind: AnalysisKind, item: PullRequestItem) {
74 const ref = analysisRef(item)
75 let isStarting = false
76 await update($, analysing, running => {
77 if (running[ref]) return running
78 isStarting = true
79 return { ...running, [ref]: { kind, headSha: item.headSha } }
80 })
81 if (!isStarting) return
82
83 const stop = async (problem: string) => {
84 await update($, analysing, ({ [ref]: _, ...others }) => others)
85 await fail($, problem)
86 }
87 const name = `${ANALYSIS_NAME[kind]} of ${refOf(item)}`
88 let spawned: AgentSpawnResult
89 try {
90 spawned = await $.agent.spawn({ prompt: analysisPrompt(kind, item), description: name, subagentType: SUBAGENT_TYPE })
91 } catch (error) {
92 await stop(`Couldn't start the ${name} (${messageOf(error)})`)
93 return
94 }
95 if (spawned.deny !== undefined) {
96 await stop(`Couldn't start the ${name} (${spawned.deny})`)
97 } else if (spawned.agentId === undefined) {
98 await stop(`Couldn't follow the ${name} (no subagent id)`)
99 } else {
100 const { agentId } = spawned
101 await update($, analysing, running => ({ ...running, [ref]: { kind, headSha: item.headSha, agentId } }))
102 await update($, handBacks, names => ({ ...names, [agentId]: name }))
103 }
104}
105
106/**
107 * The end of a subagent's turn: when it is one of this session's analyses, keeps its answer for the
108 * pull request and commit (dropping older ones of the pull request) and shows it.
109 */
110async function finish($: EngineInterface, agentId: string, end: TurnCompleteInput) {
111 const entry = Object.entries(await read($, analysing)).find(([, running]) => running.agentId === agentId)
112 if (!entry) return
113 const [ref, running] = entry
114 await update($, analysing, ({ [ref]: _, ...others }) => others)
115
116 const text = end.answer.trim()
117 if (end.reason !== 'answer' || text === '') {
118 await fail($, `The ${ANALYSIS_NAME[running.kind]} of ${ref} ended with no answer (${end.reason})`)
119 return
120 }
121 const analysis: Analysis = { kind: running.kind, headSha: running.headSha, text, analysedAt: await $.clock.now() }
122 const key = analysisKey(ref, running.headSha)
123 for (const older of (await $.store.keys()).filter(other => other.startsWith(`${ref}@`) && other !== key)) {
124 await $.store.delete(older)
125 }
126 await $.store.set(key, analysis)
127 await update($, analyses, kept => ({ ...kept, [ref]: analysis }))
128}
129
130/** Reads the analyses kept in `$.store`, by this session or another, into the panel. */
131async function readKeptAnalyses($: EngineInterface) {
132 const keys = (await $.store.keys()).filter(key => key.includes('#') && key.includes('@'))
133 const entries = await Promise.all(keys.map(async key => [key, await $.store.get(key)] as const))
134 await update($, analyses, () => latestAnalyses(entries))
135}
136
137/** Submits the pull request's latest analysis into the conversation. */
138async function continueInConversation($: EngineInterface, item: PullRequestItem) {
139 const analysis = (await read($, analyses))[analysisRef(item)]
140 if (analysis) await $.prompt.submit({ text: continuePrompt(analysis, item) })
141}
142
143async function fail($: EngineInterface, problem: string) {
144 await update($, actionProblem, () => problem)
145}
146
147hooks/options.ts 31 lines1import type { PluginOptions } from 'claude-code'
2
3import type { StaleThreshold } from './domain/age'
4import type { ReviewWindow } from './domain/review-window'
5import type { Visibility } from './domain/visibility'
6import type { MergedWindow } from './github/merged'
7
8/** The mod's `userConfig` values, typed. The engine fills in defaults and validates them before `register`. */
9export interface Options extends Visibility {
10 staleThreshold: StaleThreshold
11 reviewWindow: ReviewWindow
12 /** How far back the Merged tab looks. */
13 mergedWindow: MergedWindow
14 /** Whether the session raises Alerts. */
15 alerts: boolean
16 /** The sensitive GitHub token, used only when `gh auth token` gives none; '' when unset. */
17 githubToken: string
18}
19
20export function readOptions(options: PluginOptions): Options {
21 return {
22 staleThreshold: options.staleThreshold as StaleThreshold,
23 reviewWindow: options.reviewWindow as ReviewWindow,
24 mergedWindow: options.mergedWindow as MergedWindow,
25 alerts: options.alerts !== false,
26 githubToken: typeof options.githubToken === 'string' ? options.githubToken : '',
27 hiddenOrgs: Array.isArray(options.hiddenOrgs) ? options.hiddenOrgs : [],
28 showPrivate: options.showPrivate !== false,
29 }
30}
31hooks/pane.tsx 319 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { ActionProgress, Analysis, MergedPullRequestItem, PullRequestItem, RunningAnalysis, Snapshot, Tab, TeamSwitch } from '../types'
5import { analysisRef } from './domain/analysis'
6import { TEAM_SWITCH, splitKey } from './domain/element-key'
7import { clampOffset, offsetShowing, scrolledOffset } from './domain/list-window'
8import { TURN_COLOUR, itemRows, pullRequestItem, wrappedRows } from './items'
9import type { ItemAnalysis } from './items'
10import { SHIPMENT_COLOUR, mergedItemRows, mergedPullRequestItem } from './merged-items'
11import { EMPTY_SNAPSHOT } from './snapshot'
12
13export const PANE_ID = 'otro-nombre'
14const PANE_TITLE = 'Otro Nombre'
15
16// The engine's scan only accepts atoms declared in the file that reads and writes them.
17const tab = atom({ plugin: 'otro-nombre', key: 'tab' } as const, 'mine' as Tab)
18const legend = atom({ plugin: 'otro-nombre', key: 'legend' } as const, false)
19/** Written by `refresh.ts`. */
20const snapshot = atom({ plugin: 'otro-nombre', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
21const listOffset = atom({ plugin: 'otro-nombre', key: 'listOffset' } as const, 0)
22const focused = atom({ plugin: 'otro-nombre', key: 'focused' } as const, null as string | null)
23/** Written by `actions.ts`. */
24const actionProblem = atom({ plugin: 'otro-nombre', key: 'actionProblem' } as const, null as string | null)
25/** Written by `refresh.ts`, which runs the write actions. */
26const armed = atom({ plugin: 'otro-nombre', key: 'armed' } as const, null as { element: string; at: number } | null)
27const progress = atom({ plugin: 'otro-nombre', key: 'progress' } as const, {} as Record<string, ActionProgress>)
28/** Written by `analyses.ts`. */
29const analyses = atom({ plugin: 'otro-nombre', key: 'analyses' } as const, {} as Record<string, Analysis>)
30const analysing = atom({ plugin: 'otro-nombre', key: 'analysing' } as const, {} as Record<string, RunningAnalysis>)
31
32/**
33 * The body the pane was last drawn in. `ui.focus` carries no size, and drawing may not write
34 * `$.state`, so the scroll into view reads it here; a reload forgets it until the next draw.
35 */
36let lastBody = { rows: 40, columns: 60 }
37
38const TURN_WORDS = { mine: 'Mine', theirs: 'Theirs', done: 'Done', parked: 'Parked' } as const
39
40const TABS: readonly { id: Tab; label: string; hotkey: string }[] = [
41 { id: 'mine', label: 'Mine', hotkey: '1' },
42 { id: 'review', label: 'Review', hotkey: '2' },
43 { id: 'merged', label: 'Merged', hotkey: '3' },
44]
45
46/**
47 * The pane view: the `/prs` command that toggles the PR panel and the hooks that draw it.
48 * Drawing reads `$.state` only.
49 */
50export function registerPane(on: On) {
51 on('session.start', async ($, e, next) => {
52 await $.command.register({ name: 'prs', description: 'Open or close the PR panel' })
53 return next(e)
54 })
55
56 on('command.run', { command: 'prs' }, async $ => {
57 await togglePane($)
58 return {}
59 })
60
61 registerListScroll(on)
62
63 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
64 const { Box, Text, Button } = $.ui.resolve(e)
65 const shown = await read($, tab)
66 const isLegendShown = await read($, legend)
67 const shot = await read($, snapshot)
68 const count = (id: Tab) => listOf(shot, id).length
69 const items = listOf(shot, shown)
70 const offset = clampOffset(await read($, listOffset), items.length)
71 const focusedId = await read($, focused)
72 const footer = footerLine(shot, await read($, actionProblem))
73 const armedElement = (await read($, armed))?.element ?? null
74 const progressById = await read($, progress)
75 const analysisOf = await readAnalyses($)
76 lastBody = { rows: e.props.scroll.bodyRows, columns: e.props.bodyColumns }
77
78 const header = (
79 <Box flexDirection="row" justifyContent="space-between">
80 <Text bold>{PANE_TITLE}</Text>
81 <Box flexDirection="row">
82 {/* refresh.ts answers the press: it hooks `ui.press` on this key. */}
83 <Button key="refresh" hotkey="r" plain onPress={() => undefined}>
84 refresh
85 </Button>
86 <Text> </Text>
87 <Button key="legend" hotkey="l" plain onPress={() => update($, legend, isShown => !isShown)}>
88 legend
89 </Button>
90 </Box>
91 </Box>
92 )
93
94 const legendEntry = (key: string, colour: string, word: string) => (
95 <Box key={`legend:${key}`} flexDirection="row">
96 <Text color={colour}>●</Text>
97 <Text> {word}</Text>
98 </Box>
99 )
100 const legendBox = (
101 <Box flexDirection="column" borderStyle="round" paddingX={1}>
102 <Text bold>Turn: who moves next</Text>
103 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
104 {(['mine', 'theirs', 'done', 'parked'] as const).map(turn =>
105 legendEntry(`turn:${turn}`, TURN_COLOUR[turn], TURN_WORDS[turn]),
106 )}
107 </Box>
108 <Text bold>Shipment: CI · release · staging · production</Text>
109 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
110 {(['arrived', 'running', 'failed', 'unknown'] as const).map(step =>
111 legendEntry(`shipment:${step}`, SHIPMENT_COLOUR[step], step),
112 )}
113 </Box>
114 </Box>
115 )
116
117 const tabs = (
118 <Box flexDirection="row" gap={2}>
119 {TABS.map(({ id, label, hotkey }) => (
120 <Button
121 key={`tab:${id}`}
122 hotkey={hotkey}
123 variant={shown === id ? 'primary' : undefined}
124 onPress={() => showTab($, id)}
125 >
126 {`${label} ${count(id)}`}
127 </Button>
128 ))}
129 </Box>
130 )
131
132 // The Review tab's team switches, one a row; refresh.ts answers their presses (it hooks `ui.press` on `team:` keys).
133 const teamSwitches = (
134 <Box key="teams" flexDirection="column">
135 {shot.teams.map(team => {
136 const key = `${TEAM_SWITCH}:${team.slug}`
137 return (
138 <Box key={`teams:row:${team.slug}`} flexDirection="row">
139 <Button key={key} variant={team.isOn ? 'primary' : undefined} onPress={() => undefined}>
140 {teamLabel(team, armedElement === key)}
141 </Button>
142 </Box>
143 )
144 })}
145 </Box>
146 )
147
148 // Header, legend and tabs stay put; only the list box under them grows and clips.
149 return (
150 <Box flexDirection="column" width={e.props.bodyColumns} height={e.props.scroll.bodyRows} gap={1}>
151 <Box flexDirection="column" flexShrink={0} gap={1}>
152 {header}
153 {isLegendShown && legendBox}
154 {tabs}
155 {shown === 'review' && shot.teams.length > 0 && teamSwitches}
156 </Box>
157 <Box key="list" flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden" gap={1}>
158 {offset > 0 && <Text dimColor>{`↑ ${offset} more above`}</Text>}
159 {items.length === 0 ? (
160 <Text dimColor>No pull requests here.</Text>
161 ) : shown === 'merged' ? (
162 shot.merged.slice(offset).map(item => mergedPullRequestItem({ Box, Text, Button }, item, { isFocused: item.id === focusedId }))
163 ) : (
164 openListOf(shot, shown).slice(offset).map(item =>
165 pullRequestItem({ Box, Text, Button }, item, {
166 tab: shown,
167 isFocused: item.id === focusedId,
168 showAuthor: shown === 'review',
169 armed: armedElement,
170 progress: progressById[item.id],
171 analysis: analysisOf(item),
172 }),
173 )
174 )}
175 </Box>
176 <Box key="footer" flexShrink={0}>
177 <Text dimColor wrap="wrap">{footer}</Text>
178 </Box>
179 </Box>
180 )
181 })
182}
183
184/** Each pull request's analysis as drawn under it: the one this session runs and the latest result kept. */
185async function readAnalyses($: EngineInterface): Promise<(item: PullRequestItem) => ItemAnalysis> {
186 const results = await read($, analyses)
187 const running = await read($, analysing)
188 return item => ({ running: running[analysisRef(item)], result: results[analysisRef(item)] })
189}
190
191/** A tab shows from its top, with none of its pull requests focused yet. */
192async function showTab($: EngineInterface, id: Tab) {
193 await update($, tab, () => id)
194 await update($, listOffset, () => 0)
195 await update($, focused, () => null)
196}
197
198/**
199 * The list scrolls itself, by whole pull requests: the mod answers the pane's `ui.scroll` without
200 * `next`, so the engine's window never moves and the header, legend and tabs stay put. A `ui.focus`
201 * on an element of a pull request (keyed `<kind>:<id>`) makes it the focused pull request and
202 * scrolls it into view; one on anything else leaves none focused.
203 */
204function registerListScroll(on: On) {
205 on('ui.scroll', { requestId: PANE_ID }, async ($, e) => {
206 const count = listOf(await read($, snapshot), await read($, tab)).length
207 await update($, listOffset, offset => scrolledOffset(offset, e.by, count))
208 return {}
209 })
210
211 on('ui.focus', { requestId: PANE_ID }, async ($, e, next) => {
212 const shot = await read($, snapshot)
213 const isLegendShown = await read($, legend)
214 const shown = await read($, tab)
215 const items = listOf(shot, shown)
216 const id = e.element === undefined ? undefined : splitKey(e.element)[1]
217 const index = items.findIndex(item => item.id === id)
218 await update($, focused, () => items[index]?.id ?? null)
219 if (index >= 0) {
220 const progressById = await read($, progress)
221 const analysisOf = await readAnalyses($)
222 const heights =
223 shown === 'merged'
224 ? shot.merged.map(item => mergedItemRows(item, lastBody.columns))
225 : openListOf(shot, shown).map(item =>
226 itemRows(item, lastBody.columns, {
227 tab: shown,
228 showAuthor: shown === 'review',
229 progress: progressById[item.id],
230 analysis: analysisOf(item),
231 }),
232 )
233 const rows = listRows(lastBody, isLegendShown, footerLine(shot, await read($, actionProblem)), teamSwitchesLines(shot, shown))
234 await update($, listOffset, offset => offsetShowing(index, { offset: clampOffset(offset, items.length), heights, rows }))
235 }
236 return next(e)
237 })
238}
239
240/** The rows of the header line and of the tabs line. */
241const HEADER_ROWS = 1
242const TABS_ROWS = 1
243/** The legend's rows: its border and four lines. */
244const LEGEND_ROWS = 6
245/** The blank line the pane's `gap` puts between two blocks. */
246const GAP_ROWS = 1
247
248/**
249 * The rows the list box gets in a body: what the fixed block (header, legend, tabs, team switches),
250 * the refresh line and the blank lines between them leave.
251 */
252function listRows(body: { rows: number; columns: number }, isLegendShown: boolean, footer: string, teamSwitches: string[]): number {
253 const teamRows = teamSwitches.length === 0 ? 0 : GAP_ROWS + teamSwitches.reduce((rows, line) => rows + wrappedRows(line, body.columns), 0)
254 const fixed = HEADER_ROWS + GAP_ROWS + (isLegendShown ? LEGEND_ROWS + GAP_ROWS : 0) + TABS_ROWS + teamRows
255 return body.rows - fixed - GAP_ROWS - GAP_ROWS - wrappedRows(footer, body.columns)
256}
257
258/** The pull requests a tab lists. */
259function listOf(shot: Snapshot, id: Tab): readonly (PullRequestItem | MergedPullRequestItem)[] {
260 return id === 'merged' ? shot.merged : openListOf(shot, id)
261}
262
263/** The open pull requests a tab lists. */
264function openListOf(shot: Snapshot, id: Exclude<Tab, 'merged'>): readonly PullRequestItem[] {
265 return id === 'mine' ? shot.mine : shot.review
266}
267
268/** The team switches' lines as drawn in the shown tab, one a team, to measure them (as if armed, to err tall); none when not drawn. */
269function teamSwitchesLines(shot: Snapshot, shown: Tab): string[] {
270 return shown === 'review' ? shot.teams.map(team => teamLabel(team, true)) : []
271}
272
273/**
274 * A team switch's label: whether it is off `[ ]`, on `[x]` or on with Auto-approval `[A]`, the
275 * team and its count; armed, it asks "auto?".
276 */
277function teamLabel(team: TeamSwitch, isArmed: boolean): string {
278 const box = team.isAuto ? '[A]' : team.isOn ? '[x]' : '[ ]'
279 return `${box} ${team.slug} ${team.count}${isArmed ? ' · auto?' : ''}`
280}
281
282/** The refresh line: the last action's failure, if any, then how the last refresh went. */
283function footerLine(shot: Snapshot, problem: string | null): string {
284 return problem === null ? refreshLine(shot) : `${problem} · ${refreshLine(shot)}`
285}
286
287/** How the last refresh went: when it last succeeded, or why it failed; then whether the Promotions could be read. */
288function refreshLine(shot: Snapshot): string {
289 const promotions = shot.promotionProblem === null ? '' : ` · Couldn't read promotions (${shot.promotionProblem})`
290 return refreshOutcome(shot) + promotions
291}
292
293/** How the last refresh went: when it last succeeded, or why it failed. */
294function refreshOutcome({ updatedAt, problem }: Snapshot): string {
295 const lastUpdated = updatedAt === null ? '' : ` · last updated ${clockTime(updatedAt)}`
296 if (problem?.kind === 'no-token') {
297 return "No GitHub token: run `gh auth login`, or set the GitHub token in otro-nombre's plugin options"
298 }
299 if (problem?.kind === 'failed') return `Couldn't refresh (${problem.reason})${lastUpdated}`
300 return updatedAt === null ? 'Not refreshed yet' : `Updated ${clockTime(updatedAt)}`
301}
302
303/** `HH:MM` in the local time zone. */
304function clockTime(ms: number): string {
305 const time = new Date(ms)
306 const pad = (n: number) => String(n).padStart(2, '0')
307 return `${pad(time.getHours())}:${pad(time.getMinutes())}`
308}
309
310/** The engine's pane list is the source of truth: it survives a module reload and a close by the person. */
311async function togglePane($: EngineInterface) {
312 const panes = await $.ui.panes()
313 if (panes.some(pane => pane.id === PANE_ID)) {
314 await $.ui.close({ id: PANE_ID })
315 } else {
316 await $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true })
317 }
318}
319hooks/refresh.ts 701 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HttpInit, HttpResponse, On, Timer } from 'claude-code'
3
4import type {
5 ActionProgress,
6 PullRequestItem,
7 RefreshProblem,
8 Snapshot,
9 SnapshotCache,
10 TeamSwitch,
11 WriteActionKind,
12} from '../types'
13import { alertToasts, alertsBetween, autoApprovalAlert } from './domain/alerts'
14import { ancestryKey, ancestryToKeep, isSameAncestry, keptAncestryOf } from './domain/ancestry'
15import type { KeptAncestry } from './domain/ancestry'
16import { unlistedAnalysisKeys } from './domain/analysis'
17import { handledOf, isAutoApprovable, notHandled, withHandled } from './domain/auto-approval'
18import { ARM_MS, pressOf } from './domain/confirmation'
19import { toItem } from './domain/item'
20import { TEAM_SWITCH, splitKey } from './domain/element-key'
21import { messageOf } from './domain/format'
22import { isDue } from './domain/polling'
23import { ancestryTag, promotionOf, regionPromotion, stableVersionOf } from './domain/promotion'
24import type { Environment, Promotion, RegionPromotion, RegionVersion } from './domain/promotion'
25import type { OpenPullRequest } from './domain/pull-request'
26import { readiness } from './domain/readiness'
27import { reviewState } from './domain/review-state'
28import { reviewWindowStart } from './domain/review-window'
29import { awaitsRelease, isContained, releaseCandidates, toMergedItem } from './domain/shipment'
30import type { MergedPullRequest, Release } from './domain/shipment'
31import { statusRow } from './domain/status-row'
32import { visibleOnly } from './domain/visibility'
33import { GH_TOKEN_COMMANDS, NoToken, graphqlData, graphqlRequest, restRequest } from './github/client'
34import {
35 CIRCLECI_CONFIG,
36 appFoldersOf,
37 appTreesOf,
38 appTreesQuery,
39 blobTextsOf,
40 blobsQuery,
41 codeSearchPath,
42 freshApps,
43 imageNamesOf,
44 kargoApps,
45 regionBlobsOf,
46 regionVersionsOf,
47 uniqueApps,
48 unreadBlobs,
49 versionsOf,
50} from './github/deployments'
51import type { AppTreesData, BlobsData, CircleCiConfigData, DeploymentApp, DiscoveredApps, StableVersions, TreeEntry } from './github/deployments'
52import { MERGED_PULL_REQUESTS_SEARCH, comparePath, comparisonStatusOf, mergedPullRequestsOf, mergedPullRequestsSearch } from './github/merged'
53import type { MergedPullRequestsSearchData } from './github/merged'
54import { MY_PULL_REQUESTS, OPEN_PULL_REQUESTS_SEARCH, openPullRequestsOf } from './github/open-pull-requests'
55import type { OpenPullRequestsSearchData } from './github/open-pull-requests'
56import { VIEWER_TEAMS, reviewRequestsOf, reviewRequestsQuery, teamsOf } from './github/review-requests'
57import type { ReviewRequestsData, ViewerTeamsData } from './github/review-requests'
58import { REVIEW_THREADS, needsThreads, withUnresolvedThreads } from './github/threads'
59import type { ReviewThreadsData } from './github/threads'
60import { isWriteAction, writeRequestOf } from './github/write-actions'
61import type { WriteRequest } from './github/write-actions'
62import type { Options } from './options'
63import { PANE_ID } from './pane'
64import { EMPTY_SNAPSHOT, findOpenItem } from './snapshot'
65
66const GH_TIMEOUT_MS = 10_000
67/** The `$.store` key of the snapshot cache shared by the person's sessions. */
68const CACHE_KEY = 'snapshot-cache'
69/** How often the session looks at the shared cache; `isDue` decides whether it fetches. */
70const POLL_TICK_MS = 15_000
71
72/** The `$.store` key of the teams the person switched off (`org/team` list). */
73const TEAMS_OFF = 'teamsOff'
74/** The `$.store` key of the teams the person turned to Auto-approval (`org/team` list). */
75const AUTO_TEAMS = 'autoApprovalTeams'
76/** The `$.store` key of the Review requests the Auto-approvals dealt with (see `HandledAutoApprovals`). */
77const AUTO_HANDLED = 'autoApprovals'
78
79/**
80 * The `$.store` key of each repo's discovered app folders in the deployments repos, the org's
81 * GitOps repos (see `DiscoveredApps` and `github/deployments.ts`).
82 */
83const DEPLOYMENTS_KEY = 'deployments'
84/** The `$.store` key of the version each region file's blob promotes (see `StableVersions`). */
85const STABLE_VERSIONS_KEY = 'stable-versions'
86/** The `$.store` key of the compare answers GitHub could tell (see `KeptAncestry`). */
87const ANCESTRY_KEY = 'ancestry'
88
89// The engine's scan only accepts atoms declared in the file that reads and writes them.
90const snapshot = atom({ plugin: 'otro-nombre', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
91/** Drawn by `pane.tsx`. */
92const armed = atom({ plugin: 'otro-nombre', key: 'armed' } as const, null as { element: string; at: number } | null)
93const progress = atom({ plugin: 'otro-nombre', key: 'progress' } as const, {} as Record<string, ActionProgress>)
94
95/**
96 * The token, obtained once per module load. A module variable is fine here: it is never drawn,
97 * and a reload (a settings change included) obtains it again.
98 */
99let token: Promise<string | undefined> | undefined
100
101/** This session's poller; a module reload drops it with the old module, a second `session.start` replaces it. */
102let poller: Timer | undefined
103/** The last poll this session queued; the next one waits for it. */
104let polling: Promise<void> = Promise.resolve()
105/** The fetch this session has in flight, joined by any refresh asked for meanwhile. */
106let fetching: Promise<void> | undefined
107/** The `fetchedAt` of the cache this session last showed, so an unchanged cache is not shown again. */
108let shownFetchedAt: number | undefined
109/**
110 * The snapshot this session last showed, which the next one is told apart from in Alerts; undefined
111 * before the first, which raises none. A reload drops it with the module.
112 */
113let shownSnapshot: Snapshot | undefined
114
115/** Disarms the armed write action once its three seconds are up; a reload drops it with the module. */
116let disarmTimer: Timer | undefined
117
118/**
119 * Keeps the snapshot the pane draws and the status row: a poller started with the session
120 * shows the shared cache and fetches when it is due (60 s with the pane open, five minutes
121 * closed, backed off after failures); opening the pane polls at once, and the pane's
122 * `refresh` Button fetches now. Everything that reaches GitHub through `$` lives in this file,
123 * since the engine follows `$` only into functions declared in the same file.
124 */
125export function registerRefresh(on: On, options: Options) {
126 // Only a session a person sits at polls (a `-p` run has no panel and shows no status row).
127 // The matcher is also what lets this hook stand beside pane.tsx's own `session.start` hook.
128 // A settings change reloads the module, and a reload starts the session again: an open panel
129 // is refreshed at once so the new settings (the visibility filters among them) apply.
130 on('session.start', { isInteractive: true }, async ($, e, next) => {
131 const started = await next(e)
132 poller?.cancel()
133 poller = $.clock.every(POLL_TICK_MS, () => poll($, options))
134 void ((await isPaneOpen($)) ? refresh($, options) : poll($, options))
135 return started
136 })
137
138 on('ui.open', { id: 'otro-nombre' }, async ($, e, next) => {
139 const opened = await next(e)
140 void poll($, options)
141 return opened
142 })
143
144 on('ui.press', { plugin: 'otro-nombre', element: 'refresh' }, async ($, e, next) => {
145 void refresh($, options)
146 return next(e)
147 })
148
149 // The Review tab's team switches, keyed `team:<org/team>`, and the write actions on a pull
150 // request, keyed `<kind>:<item id>` (see `pressWriteAction`).
151 on('ui.press', { plugin: 'otro-nombre' }, async ($, e, next) => {
152 const [kind, id] = splitKey(e.element)
153 if (kind === TEAM_SWITCH) {
154 await pressTeamSwitch($, options, e.element, id)
155 } else {
156 const item = findOpenItem(await read($, snapshot), id)
157 if (item && isWriteAction(kind)) await pressWriteAction($, options, e.element, kind, item, writeRequestOf(kind, item))
158 }
159 return next(e)
160 })
161
162 // A focus move disarms the armed write action. pane.tsx hooks `ui.focus` on `{ requestId }`;
163 // this matcher keeps the two hooks apart.
164 on('ui.focus', { component: 'Pane', requestId: 'otro-nombre' }, async ($, e, next) => {
165 const held = await read($, armed)
166 if (held && held.element !== e.element) await disarm($)
167 return next(e)
168 })
169}
170
171/**
172 * Polls one at a time, so that a poll reads the cache the poll before it wrote: two polls at
173 * once (the session's first and the pane opening) would otherwise both find it due.
174 */
175function poll($: EngineInterface, options: Options): Promise<void> {
176 polling = polling.catch(() => undefined).then(() => pollOnce($, options))
177 return polling
178}
179
180/**
181 * Fetches when the shared cache is due for this session (see `isDue`), and otherwise shows
182 * the cache, which another session may have written since.
183 */
184async function pollOnce($: EngineInterface, options: Options): Promise<void> {
185 const cache = await readCache($)
186 if (isDue(cache, await $.clock.now(), await isPaneOpen($))) return refresh($, options)
187 if (cache && cache.fetchedAt !== shownFetchedAt) {
188 const before = shownSnapshot
189 await show($, cache)
190 toastAlerts($, options, before)
191 }
192}
193
194/** Fetches now, or joins the fetch this session already has in flight. */
195function refresh($: EngineInterface, options: Options): Promise<void> {
196 return fetching ?? track(fetchIntoCache($, options))
197}
198
199/**
200 * Fetches anew once the fetch in flight, if any, is done: after a write action, whose effect a
201 * fetch that started before it cannot show.
202 */
203function refreshAnew($: EngineInterface, options: Options): Promise<void> {
204 const before = fetching ?? Promise.resolve()
205 return track(before.catch(() => undefined).then(() => fetchIntoCache($, options)))
206}
207
208/** Makes `fetch` the one in flight, joined by the refreshes asked for until it is done. */
209function track(fetch: Promise<void>): Promise<void> {
210 const tracked: Promise<void> = fetch.finally(() => {
211 if (fetching === tracked) fetching = undefined
212 })
213 fetching = tracked
214 return tracked
215}
216
217/**
218 * Fetches from GitHub, derives each pull request's Turn, label and Shipment with the domain
219 * rules, and writes the snapshot to the shared cache (last writer wins) and shows it.
220 *
221 * The open pull requests and the merged ones are fetched side by side, and each list is shown and
222 * written as soon as it arrives (a fetch with no cache before it writes once both are in): the
223 * slower merged fetch never holds back the Mine and Review tabs. A failed fetch keeps its cached list; once both are done, the
224 * snapshot records the problem, if any, the cache counts the failure for the back-off, and the
225 * session toasts the fetch's Alerts.
226 */
227async function fetchIntoCache($: EngineInterface, options: Options): Promise<void> {
228 const last = await readCache($)
229 let shot: Snapshot = { ...EMPTY_SNAPSHOT, ...last?.snapshot }
230 // A session that has shown nothing yet tells this fetch apart from what its sessions last fetched.
231 const before = shownSnapshot ?? (last ? shot : undefined)
232 // With no cache before it, the snapshot holds both lists only once the fetch is done: written
233 // sooner, another session would take half a snapshot for a whole one.
234 let isWhole = last !== undefined
235 const failuresBefore = last?.failures ?? 0
236 /** Applies `change` to this fetch's snapshot, writes it to the shared cache once whole, and shows it. */
237 const amend = async (change: (current: Snapshot) => Snapshot, failures = failuresBefore) => {
238 shot = change(shot)
239 const cache: SnapshotCache = { snapshot: shot, fetchedAt: await $.clock.now(), failures }
240 if (isWhole) await $.store.set(CACHE_KEY, cache)
241 await show($, cache)
242 }
243 const results = await Promise.allSettled([
244 fetchOpen($, options).then(async ({ autoApprovable, ...lists }) => {
245 await amend(current => ({ ...current, ...lists }))
246 await forgetUnlistedAnalyses($, [...lists.mine, ...lists.review])
247 await autoApprove($, options, autoApprovable)
248 }),
249 fetchMerged($, options).then(merged => amend(current => ({ ...current, ...merged }))),
250 ])
251 const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
252 isWhole = true
253 await amend(current => ({ ...current, problem: failure ? problemOf(failure.reason) : null }), failure ? failuresBefore + 1 : 0)
254 toastAlerts($, options, before)
255}
256
257/**
258 * The open pull requests' lists: My pull requests and Review requests with their Turn and label,
259 * and the teams; and the Review requests that qualify for an Auto-approval.
260 */
261async function fetchOpen(
262 $: EngineInterface,
263 options: Options,
264): Promise<Pick<Snapshot, 'mine' | 'review' | 'teams' | 'updatedAt'> & { autoApprovable: PullRequestItem[] }> {
265 const data = await graphql<OpenPullRequestsSearchData>($, options, OPEN_PULL_REQUESTS_SEARCH, { q: MY_PULL_REQUESTS })
266 const prs = await withThreads($, options, visibleOnly(openPullRequestsOf(data), options))
267 const { review, teams, autoApprovable } = await reviewRequests($, options)
268 const now = await $.clock.now()
269 const mine = prs.map(pr => toItem(pr, readiness(pr), now, options.staleThreshold))
270 return { mine, review, teams, autoApprovable, updatedAt: now }
271}
272
273/**
274 * The pull requests merged within the merged window, each with its Shipment; none when the window
275 * is off. A failed Promotions lookup fails only staging and production: they show unknown this
276 * round, with the reason, while CI and release still update.
277 */
278async function fetchMerged($: EngineInterface, options: Options): Promise<Pick<Snapshot, 'merged' | 'promotionProblem'>> {
279 const q = mergedPullRequestsSearch(options.mergedWindow, await $.clock.now())
280 if (q === null) return { merged: [], promotionProblem: null }
281 const data = await graphql<MergedPullRequestsSearchData>($, options, MERGED_PULL_REQUESTS_SEARCH, { q })
282 const prs = visibleOnly(mergedPullRequestsOf(data), options)
283 let regionsOf = (_repository: string): RegionVersion[] => []
284 let promotionProblem: string | null = null
285 try {
286 regionsOf = await promotedRegions($, options, [...new Set(prs.map(pr => pr.repository))])
287 } catch (error) {
288 promotionProblem = messageOf(error)
289 }
290 const now = await $.clock.now()
291 const ancestry: Ancestry = { kept: keptAncestryOf(await $.store.get(ANCESTRY_KEY)), answers: new Map() }
292 const items = await Promise.all(prs.map(async pr => {
293 const release = await containingRelease($, options, pr, ancestry)
294 const promotion = await regionsPromotion($, options, pr, regionsOf(pr.repository), ancestry)
295 return toMergedItem(pr, release, promotion, now)
296 }))
297 const toKeep = ancestryToKeep(ancestry.answers)
298 if (!isSameAncestry(toKeep, ancestry.kept)) await $.store.set(ANCESTRY_KEY, toKeep)
299 return { merged: items, promotionProblem }
300}
301
302/**
303 * One merged fetch's answers to whether a release tag contains a merge commit: the ones kept in
304 * the store by earlier refreshes, and the ones this fetch used, by `ancestryKey`.
305 */
306interface Ancestry {
307 kept: KeptAncestry
308 answers: Map<string, boolean | null>
309}
310
311/**
312 * The earliest release whose tag contains the merge commit, checked by commit ancestry one
313 * candidate at a time, earliest first; null when none does or the ship status does not hang on one.
314 * `ancestry` keeps the answers for the pull request's Promotion.
315 */
316async function containingRelease(
317 $: EngineInterface,
318 options: Options,
319 pr: MergedPullRequest,
320 ancestry: Ancestry,
321): Promise<Release | null> {
322 if (!awaitsRelease(pr)) return null
323 for (const release of releaseCandidates(pr)) {
324 if (await containsMergeCommit($, options, pr, release.tag, ancestry)) return release
325 }
326 return null
327}
328
329/**
330 * Whether the release tagged `tag` contains the merge commit, by GitHub's compare; null when it
331 * cannot be told (no merge commit, or GitHub knows no such tag). A tag is compared once per
332 * refresh, and an answer GitHub could tell never again: `ancestry` keeps the answers.
333 */
334async function containsMergeCommit(
335 $: EngineInterface,
336 options: Options,
337 pr: MergedPullRequest,
338 tag: string,
339 ancestry: Ancestry,
340): Promise<boolean | null> {
341 if (!pr.mergeCommit) return null
342 const key = ancestryKey(pr.repository, tag, pr.mergeCommit.oid)
343 if (!ancestry.answers.has(key)) ancestry.answers.set(key, ancestry.kept[key] ?? (await compare($, options, pr.repository, tag, pr.mergeCommit.oid)))
344 return ancestry.answers.get(key) ?? null
345}
346
347/** Whether `tag` contains the commit `oid` in `repository`, by GitHub's compare; null when GitHub knows no such tag or commit. */
348async function compare($: EngineInterface, options: Options, repository: string, tag: string, oid: string): Promise<boolean | null> {
349 const status = comparisonStatusOf(await rest($, options, comparePath(repository, tag, oid)))
350 return status === null ? null : isContained(status)
351}
352
353/**
354 * A merged pull request's Promotion to staging and production from the version each region runs
355 * (`regions`, none without a deployments setup), one region at a time so a tag is compared once.
356 */
357async function regionsPromotion(
358 $: EngineInterface,
359 options: Options,
360 pr: MergedPullRequest,
361 regions: RegionVersion[],
362 ancestry: Ancestry,
363): Promise<Promotion> {
364 const promotions: { environment: Environment; promotion: RegionPromotion }[] = []
365 for (const region of regions) {
366 const tag = ancestryTag(pr, region.version)
367 const contains = tag === null ? null : await containsMergeCommit($, options, pr, tag, ancestry)
368 promotions.push({ environment: region.environment, promotion: regionPromotion(pr, region.version, contains) })
369 }
370 return promotionOf(promotions)
371}
372
373/**
374 * The regions each repo is deployed to, with the version each runs: the repos' apps discovered
375 * once (see `deploymentApps`), then every app folder read at once (see `regionVersions`).
376 */
377async function promotedRegions($: EngineInterface, options: Options, repositories: string[]): Promise<(repository: string) => RegionVersion[]> {
378 const appsOf = new Map<string, DeploymentApp[]>()
379 // One repo at a time: code search allows few requests a minute.
380 for (const repository of repositories) appsOf.set(repository, await deploymentApps($, options, repository))
381 const apps = [...appsOf.values()].flat()
382 const versions = await regionVersions($, options, apps)
383 return repository => (appsOf.get(repository) ?? []).flatMap(app => versions[apps.indexOf(app)] ?? [])
384}
385
386/**
387 * A repo's app folders in the deployments repos of its org, discovered once and kept in the
388 * store shared by the person's sessions; discovered again after a day, so a setup made since shows.
389 */
390async function deploymentApps($: EngineInterface, options: Options, repository: string): Promise<DeploymentApp[]> {
391 const now = await $.clock.now()
392 const known = freshApps((await $.store.get(DEPLOYMENTS_KEY)) as DiscoveredApps | undefined, repository, now)
393 if (known) return known
394 const apps = await discoverApps($, options, repository)
395 const latest = (await $.store.get(DEPLOYMENTS_KEY)) as DiscoveredApps | undefined
396 await $.store.set(DEPLOYMENTS_KEY, { ...latest, [repository]: { apps, checkedAt: now } })
397 return apps
398}
399
400/**
401 * A repo's app folders (ADR 0001): its images, named as `repository:` in its CircleCI config; the
402 * folders of the `values.yaml` files naming one in its org; of those, the ones holding a `kargo` entry.
403 */
404async function discoverApps($: EngineInterface, options: Options, repository: string): Promise<DeploymentApp[]> {
405 const [owner = '', name = ''] = repository.split('/')
406 const images = imageNamesOf(await graphql<CircleCiConfigData>($, options, CIRCLECI_CONFIG, { owner, name }))
407 const found: DeploymentApp[][] = []
408 for (const image of images) found.push(appFoldersOf(await rest($, options, codeSearchPath(image, owner))))
409 const folders = uniqueApps(found)
410 if (folders.length === 0) return []
411 return kargoApps(folders, await appTrees($, options, folders))
412}
413
414/** Each app folder's entries, read in one query. */
415async function appTrees($: EngineInterface, options: Options, apps: DeploymentApp[]): Promise<TreeEntry[][]> {
416 const { query, variables } = appTreesQuery(apps)
417 return appTreesOf(await graphql<AppTreesData>($, options, query, variables), apps.length)
418}
419
420/**
421 * Each app's regions with the version each runs: one tree query for every app folder, and the
422 * region files read only when their blob is new; the store keeps each blob's version, holding
423 * only the blobs of this read so it never outgrows the deployments repos.
424 */
425async function regionVersions($: EngineInterface, options: Options, apps: DeploymentApp[]): Promise<RegionVersion[][]> {
426 if (apps.length === 0) return []
427 const blobs = regionBlobsOf(apps, await appTrees($, options, apps))
428 const known = ((await $.store.get(STABLE_VERSIONS_KEY)) ?? {}) as StableVersions
429 const unread = unreadBlobs(blobs, known)
430 const read: StableVersions = {}
431 if (unread.length > 0) {
432 const { query, variables } = blobsQuery(unread)
433 const texts = blobTextsOf(await graphql<BlobsData>($, options, query, variables), unread.length)
434 unread.forEach((blob, index) => { read[blob.oid] = stableVersionOf(texts[index] ?? '') })
435 }
436 const versions = versionsOf(blobs, known, read)
437 if (unread.length > 0 || Object.keys(known).length !== Object.keys(versions).length) await $.store.set(STABLE_VERSIONS_KEY, versions)
438 return regionVersionsOf(blobs, versions)
439}
440
441/**
442 * Deletes from the store the analyses (see `analyses.ts`) of the pull requests no longer in the
443 * open lists, merged or closed since: nothing would ever draw them again.
444 */
445async function forgetUnlistedAnalyses($: EngineInterface, listed: PullRequestItem[]): Promise<void> {
446 for (const key of unlistedAnalysisKeys(await $.store.keys(), listed)) await $.store.delete(key)
447}
448
449/** Draws `cache`'s snapshot in the pane and its counts in the status row. */
450async function show($: EngineInterface, cache: SnapshotCache): Promise<void> {
451 shownFetchedAt = cache.fetchedAt
452 const shot: Snapshot = { ...EMPTY_SNAPSHOT, ...cache.snapshot }
453 shownSnapshot = shot
454 await update($, snapshot, () => shot)
455 $.ui.status(statusRow(shot))
456}
457
458/** How long an Alert's toast stays. */
459const ALERT_TIMEOUT_MS = 10_000
460
461/**
462 * Toasts the Alerts from `before` to the snapshot shown since, unless the Alerts setting is off;
463 * none when this session had shown nothing before.
464 */
465function toastAlerts($: EngineInterface, options: Options, before: Snapshot | undefined): void {
466 if (!options.alerts || !before || !shownSnapshot) return
467 for (const text of alertToasts(alertsBetween(before, shownSnapshot))) $.ui.toast(text, { timeoutMs: ALERT_TIMEOUT_MS })
468}
469
470/** Whether this session's panel is open, as the engine's pane list says. */
471async function isPaneOpen($: EngineInterface): Promise<boolean> {
472 return (await $.ui.panes()).some(pane => pane.id === PANE_ID)
473}
474
475/** The shared cache, or undefined when no session has written one. */
476async function readCache($: EngineInterface): Promise<SnapshotCache | undefined> {
477 return ((await $.store.get(CACHE_KEY)) ?? undefined) as SnapshotCache | undefined
478}
479
480/**
481 * The Review requests of the person's teams switched on, plus their dismissed approvals, with
482 * every team's switch and count, and the ones that qualify for an Auto-approval. Two queries: the
483 * teams, then every team's search at once.
484 */
485async function reviewRequests(
486 $: EngineInterface,
487 options: Options,
488): Promise<{ review: PullRequestItem[]; teams: TeamSwitch[]; autoApprovable: PullRequestItem[] }> {
489 const off = await teamList($, TEAMS_OFF)
490 const auto = await teamList($, AUTO_TEAMS)
491 const teamsData = await graphql<ViewerTeamsData>($, options, VIEWER_TEAMS, {})
492 const teams = teamsOf(teamsData, options).map(slug => {
493 const isOn = !off.includes(slug)
494 return { slug, isOn, isAuto: isOn && auto.includes(slug) }
495 })
496 const now = await $.clock.now()
497 const search = reviewRequestsQuery(teams, reviewWindowStart(now, options.reviewWindow), options)
498 const data = await graphql<ReviewRequestsData>($, options, search.query, search.variables)
499 const { requests, counts } = reviewRequestsOf(data, teams)
500 const visible = visibleOnly(requests, options)
501 const review = visible.map(pr => toItem(pr, reviewState(pr), now, options.staleThreshold))
502 const autoTeams = teams.filter(team => team.isAuto).map(team => team.slug)
503 const approvable = new Set(visible.filter(pr => isAutoApprovable(pr, autoTeams)).map(pr => pr.id))
504 return {
505 review,
506 teams: teams.map((team, index) => ({ ...team, count: counts[index] ?? 0 })),
507 autoApprovable: review.filter(item => approvable.has(item.id)),
508 }
509}
510
511/** A list of teams (`org/team`) from the store shared by every session: the ones switched off, or turned to Auto-approval. */
512async function teamList($: EngineInterface, key: typeof TEAMS_OFF | typeof AUTO_TEAMS): Promise<string[]> {
513 const stored = await $.store.get(key)
514 return Array.isArray(stored) ? stored.filter(slug => typeof slug === 'string') : []
515}
516
517/** Where a team switch stands: off, on, or on with Auto-approval. */
518type TeamMode = 'off' | 'on' | 'auto'
519
520/**
521 * A press on a team switch, keyed `team:<org/team>`: off turns on, Auto-approval turns off, and on
522 * arms the switch (`pane.tsx` draws it with "auto?"). A second press within `ARM_MS` turns it to
523 * Auto-approval; none switches it off when the three seconds are up, so that switching a team off
524 * never passes through Auto-approval. A focus move or another press disarms it, leaving it on.
525 */
526async function pressTeamSwitch($: EngineInterface, options: Options, element: string, slug: string): Promise<void> {
527 const team = (await read($, snapshot)).teams.find(each => each.slug === slug)
528 if (!team) return
529 if (!team.isOn) return setTeamMode($, options, slug, 'on')
530 if (team.isAuto) return setTeamMode($, options, slug, 'off')
531 const now = await $.clock.now()
532 if (pressOf(await read($, armed), element, now) === 'arm') {
533 disarmTimer?.cancel()
534 await update($, armed, () => ({ element, at: now }))
535 disarmTimer = $.clock.after(ARM_MS, () => void disarm($).then(() => setTeamMode($, options, slug, 'off')))
536 return
537 }
538 await disarm($)
539 await setTeamMode($, options, slug, 'auto')
540}
541
542/**
543 * Sets a team switch for every session, shows it at once, and refreshes the lists. Turning a team to
544 * Auto-approval leaves alone the Review requests listed then, at their current commits: it covers
545 * only what arrives after.
546 */
547async function setTeamMode($: EngineInterface, options: Options, slug: string, mode: TeamMode): Promise<void> {
548 const without = (teams: string[]) => teams.filter(other => other !== slug)
549 const off = without(await teamList($, TEAMS_OFF))
550 const auto = without(await teamList($, AUTO_TEAMS))
551 await $.store.set(TEAMS_OFF, mode === 'off' ? [...off, slug] : off)
552 await $.store.set(AUTO_TEAMS, mode === 'auto' ? [...auto, slug] : auto)
553 if (mode === 'auto') {
554 const handled = handledOf(await $.store.get(AUTO_HANDLED))
555 await $.store.set(AUTO_HANDLED, withHandled(handled, (await read($, snapshot)).review, await $.clock.now()))
556 }
557 await update($, snapshot, shot => ({
558 ...shot,
559 teams: shot.teams.map(team => (team.slug === slug ? { ...team, isOn: mode !== 'off', isAuto: mode === 'auto' } : team)),
560 }))
561 void refresh($, options)
562}
563
564/**
565 * Gives an Auto-approval to each of `prs` at a commit no session has dealt with yet: it records them
566 * as handled first, so that no other session approves them again, nor this one after a failure; a
567 * failure shows on the item as a refused approval does. Each approval raises an Alert, unless the
568 * Alerts setting is off; the lists are then fetched anew.
569 */
570async function autoApprove($: EngineInterface, options: Options, prs: PullRequestItem[]): Promise<void> {
571 const handled = handledOf(await $.store.get(AUTO_HANDLED))
572 const due = notHandled(prs, handled)
573 if (due.length === 0) return
574 await $.store.set(AUTO_HANDLED, withHandled(handled, due, await $.clock.now()))
575 const approved: PullRequestItem[] = []
576 for (const item of due) {
577 if (await sendWriteAction($, options, 'approve', item, writeRequestOf('approve', item))) approved.push(item)
578 }
579 if (options.alerts) {
580 for (const text of alertToasts(approved.map(autoApprovalAlert))) $.ui.toast(text, { timeoutMs: ALERT_TIMEOUT_MS })
581 }
582 void refreshAnew($, options)
583}
584
585/**
586 * A press on a write action (squash merge, update branch, close): a Button keyed `<kind>:<item id>`
587 * whose kind has a mutation in `github/write-actions.ts`. Write actions live here, beside the token
588 * and the refresh they need. The first press arms the Button (`items.tsx` draws it as
589 * "Confirm …?"); a second press within `ARM_MS` runs the action; the clock, a focus move or
590 * another write action's press disarms it.
591 */
592async function pressWriteAction(
593 $: EngineInterface,
594 options: Options,
595 element: string,
596 kind: WriteActionKind,
597 item: PullRequestItem,
598 request: WriteRequest,
599): Promise<void> {
600 const now = await $.clock.now()
601 if (pressOf(await read($, armed), element, now) === 'arm') {
602 disarmTimer?.cancel()
603 await update($, armed, () => ({ element, at: now }))
604 disarmTimer = $.clock.after(ARM_MS, () => void disarm($))
605 return
606 }
607 await disarm($)
608 // One write action at a time on a pull request.
609 if ((await read($, progress))[item.id]?.phase === 'running') return
610 void runWriteAction($, options, kind, item, request)
611}
612
613/** Runs a confirmed write action, shows its progress on the item, then refreshes the lists. */
614async function runWriteAction($: EngineInterface, options: Options, kind: WriteActionKind, item: PullRequestItem, request: WriteRequest) {
615 await sendWriteAction($, options, kind, item, request)
616 await refreshAnew($, options)
617}
618
619/** Sends a write action's mutation and shows its progress on the item; answers whether GitHub took it. */
620async function sendWriteAction(
621 $: EngineInterface,
622 options: Options,
623 kind: WriteActionKind,
624 item: PullRequestItem,
625 request: WriteRequest,
626): Promise<boolean> {
627 const show = (shown: ActionProgress) => update($, progress, all => ({ ...all, [item.id]: shown }))
628 await show({ kind, phase: 'running' })
629 try {
630 await graphql($, options, request.query, request.variables)
631 await show({ kind, phase: 'done' })
632 return true
633 } catch (error) {
634 await show({ kind, phase: 'failed', problem: messageOf(error) })
635 return false
636 }
637}
638
639async function disarm($: EngineInterface): Promise<void> {
640 disarmTimer?.cancel()
641 disarmTimer = undefined
642 await update($, armed, () => null)
643}
644
645/** `prs` with their unresolved review threads, read by a second query for the ones that need them. */
646async function withThreads($: EngineInterface, options: Options, prs: OpenPullRequest[]): Promise<OpenPullRequest[]> {
647 const ids = prs.filter(needsThreads).map(pr => pr.id)
648 if (ids.length === 0) return prs
649 const data = await graphql<ReviewThreadsData>($, options, REVIEW_THREADS, { ids })
650 return withUnresolvedThreads(prs, data)
651}
652
653/** Runs a GraphQL query over the host's HTTP fetch; rejects with `NoToken` or `GitHubError`. */
654async function graphql<Data>($: EngineInterface, options: Options, query: string, variables: Record<string, unknown>): Promise<Data> {
655 const request = graphqlRequest(await authorisation($, options), query, variables)
656 return graphqlData<Data>(await send($, request))
657}
658
659/** Runs a REST GET over the host's HTTP fetch and answers GitHub's response; rejects with `NoToken`. */
660async function rest($: EngineInterface, options: Options, path: string): Promise<HttpResponse> {
661 return send($, restRequest(await authorisation($, options), path))
662}
663
664/** Sends a request to GitHub over the host's HTTP fetch; a revoked token is obtained again next time. */
665async function send($: EngineInterface, request: { url: string; init: HttpInit }): Promise<HttpResponse> {
666 const response = await $.http.fetch(request.url, request.init)
667 if (response.status === 401) token = undefined
668 return response
669}
670
671/**
672 * The token, from `gh auth token` or else the GitHub token setting; rejects with `NoToken`.
673 * Requests sent side by side share one `gh auth token` run; without a token, the next refresh asks again.
674 */
675async function authorisation($: EngineInterface, options: Options): Promise<string> {
676 token ??= ghToken($).then(fromGh => fromGh ?? (options.githubToken || undefined))
677 const obtained = await token
678 if (obtained) return obtained
679 token = undefined
680 throw new NoToken()
681}
682
683/** The token `gh auth token` prints, or undefined when gh is logged out or cannot be started. */
684async function ghToken($: EngineInterface): Promise<string | undefined> {
685 for (const argv of GH_TOKEN_COMMANDS) {
686 try {
687 const run = await $.process.run(argv, { timeoutMs: GH_TIMEOUT_MS })
688 // This gh ran: it has a token or it has none, and another path would say the same.
689 return run.exitCode === 0 ? run.stdout.trim() || undefined : undefined
690 } catch {
691 // This gh could not be started: try the next path.
692 }
693 }
694 return undefined
695}
696
697function problemOf(error: unknown): RefreshProblem {
698 if (error instanceof NoToken) return { kind: 'no-token' }
699 return { kind: 'failed', reason: messageOf(error) }
700}
701hooks/domain/element-key.ts 15 lines1/**
2 * The pane's element keys: an element of a pull request is keyed `<kind>:<item id>` (`pr:PR_12`,
3 * `copy:PR_12`, `merge:PR_12`), a team switch `team:<org/team>`, and the hooks that answer a press
4 * or a focus split the key back.
5 */
6
7/** The kind of a team switch's key. */
8export const TEAM_SWITCH = 'team'
9
10/** `merge:PR_12` -> `['merge', 'PR_12']`; a key without a colon has no item id. */
11export function splitKey(element: string): [string, string] {
12 const colon = element.indexOf(':')
13 return colon < 0 ? [element, ''] : [element.slice(0, colon), element.slice(colon + 1)]
14}
15hooks/domain/format.ts 22 lines1/** A pull request as far as its name reads it. */
2export interface Named {
3 number: number
4 /** `owner/name`. */
5 repo: string
6}
7
8/** `#12 owner/name`: a pull request as the refresh line and the analyses name it. */
9export function refOf(pr: Named): string {
10 return `#${pr.number} ${pr.repo}`
11}
12
13/** `#12 name`: a pull request as its list item draws it, its owner left out. */
14export function shortRefOf(pr: Named): string {
15 return `#${pr.number} ${pr.repo.slice(pr.repo.indexOf('/') + 1)}`
16}
17
18/** What a failure says: an error's message, or anything else thrown as text. */
19export function messageOf(error: unknown): string {
20 return error instanceof Error ? error.message : String(error)
21}
22hooks/snapshot.ts 26 lines1import type { MergedPullRequestItem, PullRequestItem, Snapshot } from '../types'
2
3/**
4 * The snapshot before the first refresh. The `snapshot` value itself is declared as an atom in
5 * each file that touches it (the engine's scan wants that): `refresh.ts` writes it, `pane.tsx` reads it.
6 */
7export const EMPTY_SNAPSHOT: Snapshot = {
8 mine: [],
9 review: [],
10 teams: [],
11 merged: [],
12 promotionProblem: null,
13 updatedAt: null,
14 problem: null,
15}
16
17/** The pull request with node id `id` in any of the snapshot's lists, merged ones included. */
18export function findItem(shot: Snapshot, id: string): PullRequestItem | MergedPullRequestItem | undefined {
19 return findOpenItem(shot, id) ?? shot.merged.find(item => item.id === id)
20}
21
22/** The open pull request with node id `id`: a My pull request or a Review request. */
23export function findOpenItem(shot: Snapshot, id: string): PullRequestItem | undefined {
24 return [...shot.mine, ...shot.review].find(item => item.id === id)
25}
26hooks/domain/analysis.ts 152 lines1import type { PromptOrigin } from 'claude-code'
2
3import type { Analysis, AnalysisActionKind, AnalysisKind, PullRequestItem } from '../../types'
4import { QUILL_REVIEWER } from './readiness'
5
6/** What each analysis is called, on its action and its result. */
7export const ANALYSIS_NAME: Record<AnalysisKind, string> = { review: 'Review analysis', unblock: 'Unblock analysis' }
8
9/** The action that runs each analysis: its Button is keyed `<action>:<item id>`. */
10export const ANALYSIS_ACTION: Record<AnalysisKind, AnalysisActionKind> = { review: 'review-analysis', unblock: 'unblock-analysis' }
11
12/** The analysis an action runs; undefined for an action that runs none. */
13export function analysisOfAction(action: string): AnalysisKind | undefined {
14 return (Object.keys(ANALYSIS_ACTION) as AnalysisKind[]).find(kind => ANALYSIS_ACTION[kind] === action)
15}
16
17/** The origins of what the person sends: never dropped, even when it quotes a hand-back. */
18const PERSON_ORIGINS: readonly PromptOrigin['kind'][] = ['composer', 'bridge', 'sdk']
19
20/**
21 * The analysis whose subagent's report a submission hands into the conversation, from `names` (each
22 * analysis this session started, by its subagent's id); undefined when it hands back none of them or
23 * the person sent it. The engine frames a hand-back `<agent-message from="<agentId>">`: that wording
24 * is the engine's, not its API, so if it changes no hand-back is found and the report goes through.
25 */
26export function handedBackAnalysis(
27 origin: PromptOrigin,
28 text: string,
29 names: Readonly<Record<string, string>>,
30): { agentId: string; name: string } | undefined {
31 if (PERSON_ORIGINS.includes(origin.kind)) return undefined
32 const agentId = /<agent-message from="([^"]+)">/.exec(text)?.[1]
33 const name = agentId === undefined ? undefined : names[agentId]
34 return agentId !== undefined && name !== undefined ? { agentId, name } : undefined
35}
36
37/** The pull request an analysis is of: `owner/repo#number`. */
38export function analysisRef(item: PullRequestItem): string {
39 return `${item.repo}#${item.number}`
40}
41
42/** The `$.store` key of an analysis of the pull request `ref` (`owner/repo#number`) at a commit: `owner/repo#number@headSha`. */
43export function analysisKey(ref: string, headSha: string): string {
44 return `${ref}@${headSha}`
45}
46
47/** A store key of an analysis, split into the pull request and the commit; undefined for any other key. */
48function parseAnalysisKey(key: string): { ref: string; headSha: string } | undefined {
49 const match = /^([^#@/]+\/[^#@/]+#\d+)@(\w+)$/.exec(key)
50 return match?.[1] && match[2] ? { ref: match[1], headSha: match[2] } : undefined
51}
52
53/** The store keys among `keys` of analyses of pull requests not in `listed`: the ones to forget. */
54export function unlistedAnalysisKeys(keys: readonly string[], listed: readonly PullRequestItem[]): string[] {
55 const refs = new Set(listed.map(analysisRef))
56 return keys.filter(key => {
57 const parsed = parseAnalysisKey(key)
58 return parsed !== undefined && !refs.has(parsed.ref)
59 })
60}
61
62/** Whether the pull request has moved on from the commit the analysis read. */
63export function isOutOfDate(analysis: Analysis, item: PullRequestItem): boolean {
64 return analysis.headSha !== item.headSha
65}
66
67/** The latest analysis of each pull request among the store's entries, by `owner/repo#number`. */
68export function latestAnalyses(entries: readonly (readonly [string, unknown])[]): Record<string, Analysis> {
69 const latest: Record<string, Analysis> = {}
70 for (const [key, value] of entries) {
71 const parsed = parseAnalysisKey(key)
72 if (!parsed || !isAnalysis(value)) continue
73 const known = latest[parsed.ref]
74 if (!known || value.analysedAt > known.analysedAt) latest[parsed.ref] = value
75 }
76 return latest
77}
78
79function isAnalysis(value: unknown): value is Analysis {
80 if (typeof value !== 'object' || value === null) return false
81 const candidate = value as Partial<Analysis>
82 return typeof candidate.kind === 'string' && typeof candidate.headSha === 'string' && typeof candidate.text === 'string' && typeof candidate.analysedAt === 'number'
83}
84
85/** The prompt of the background subagent that runs a `kind` analysis of `item`. */
86export function analysisPrompt(kind: AnalysisKind, item: PullRequestItem): string {
87 switch (kind) {
88 case 'review':
89 return reviewAnalysisPrompt(item)
90 case 'unblock':
91 return unblockAnalysisPrompt(item)
92 }
93}
94
95/** Where every analysis prompt starts: the pull request, its head commit, how to read it, and to change nothing. */
96function pullRequestBrief(item: PullRequestItem): string {
97 return [
98 `Pull request: ${item.url} (#${item.number} in ${item.repo}, "${item.title}" by ${item.author}), head commit ${item.headSha}.`,
99 `Read it with the GitHub CLI: \`gh pr view ${item.url} --comments\` and \`gh pr diff ${item.url}\`; read files of the repository at that commit with \`gh api\` when the diff is not enough.`,
100 'Do not change anything: no comments, reviews, approvals, pushes, branches or file edits. Only read.',
101 ].join('\n')
102}
103
104/** How every analysis answer is shaped: it is drawn as plain text in a narrow panel. */
105const ANSWER_SHAPE =
106 'Answer in plain text, no Markdown: a short line per point, at most about 250 words, as each heading on its own line followed by its points starting with "- ".'
107
108function reviewAnalysisPrompt(item: PullRequestItem): string {
109 return [
110 'Analyse this pull request for the person about to review it.',
111 pullRequestBrief(item),
112 'Answer under three headings:',
113 'What it does: the change in a few lines.',
114 'Risks: what could break or regress, security or data concerns, missing tests.',
115 'What to look at: the files and lines a reviewer should read first, and why.',
116 ANSWER_SHAPE,
117 ].join('\n\n')
118}
119
120/**
121 * The panel knows only the pull request's Readiness (the first blocker of its ladder) and whether
122 * it is behind; the subagent fetches the rest of the blockers itself with `gh`.
123 */
124function unblockAnalysisPrompt(item: PullRequestItem): string {
125 const known = [`Its Readiness: ${item.label}.`]
126 if (item.isBehind) known.push('Its branch is behind its base branch.')
127 return [
128 'Analyse this pull request for its author, who wants to merge it.',
129 pullRequestBrief(item),
130 known.join(' '),
131 [
132 'The Readiness names only the first blocker found; check them all:',
133 `- Failing checks: \`gh pr checks ${item.url}\`, then \`gh run view <run id> --log-failed\` for each failing run.`,
134 `- Merge conflicts, behind its base branch, changes requested: \`gh pr view ${item.url} --json mergeable,mergeStateStatus,reviewDecision,reviews,baseRefName\`.`,
135 `- Unresolved review threads, and which of them are Quill's (author \`${QUILL_REVIEWER}\`): \`gh api graphql\` on the pull request's \`reviewThreads\` (\`isResolved\`, \`path\`, \`line\`, and each comment's author login and body).`,
136 ].join('\n'),
137 'Answer under two headings:',
138 'Why it is not ready: each blocker, with the failing check, conflicting files or thread it comes from.',
139 'How to fix it: the steps that clear each blocker, in order.',
140 ANSWER_SHAPE,
141 ].join('\n\n')
142}
143
144/** The prompt that carries an analysis into the conversation. */
145export function continuePrompt(analysis: Analysis, item: PullRequestItem): string {
146 return [
147 `Here is the ${ANALYSIS_NAME[analysis.kind]} of ${item.url} (#${item.number} in ${item.repo}, "${item.title}") at commit ${analysis.headSha.slice(0, 7)}:`,
148 analysis.text,
149 'Let us continue from it.',
150 ].join('\n\n')
151}
152hooks/domain/age.ts 17 lines1const DAY_MS = 86_400_000
2
3/** The stale threshold setting's choices, as `userConfig` stores them. */
4export type StaleThreshold = 'off' | '2w' | '1m' | '3m' | '6m'
5
6const STALE_AFTER_DAYS: Record<Exclude<StaleThreshold, 'off'>, number> = { '2w': 14, '1m': 30, '3m': 90, '6m': 180 }
7
8/** Whole days from `createdAt` (ISO 8601) to `now` (epoch milliseconds). */
9export function ageInDays(createdAt: string, now: number): number {
10 return Math.floor((now - Date.parse(createdAt)) / DAY_MS)
11}
12
13/** Whether a pull request created at `createdAt` is older than the stale threshold. */
14export function isStale(createdAt: string, now: number, threshold: StaleThreshold): boolean {
15 return threshold !== 'off' && ageInDays(createdAt, now) > STALE_AFTER_DAYS[threshold]
16}
17hooks/domain/review-window.ts 13 lines1const DAY_MS = 86_400_000
2
3/** The review window setting's choices, as `userConfig` stores them. */
4export type ReviewWindow = 'off' | '1w' | '2w' | '1m'
5
6const WINDOW_DAYS: Record<Exclude<ReviewWindow, 'off'>, number> = { '1w': 7, '2w': 14, '1m': 30 }
7
8/** The UTC day (`YYYY-MM-DD`) the review window starts on at `now`; null when the window is off. */
9export function reviewWindowStart(now: number, window: ReviewWindow): string | null {
10 if (window === 'off') return null
11 return new Date(now - WINDOW_DAYS[window] * DAY_MS).toISOString().slice(0, 10)
12}
13