SLOPSHOPPER

otro-nombre

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

newpanecommandtoaststatusprompt
v0.4.0no licenseupdated 2026-10-23escorponox/otro-nombre/otro-nombre
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · otro-nombre
│ ┃ Otro Nombre ✕ › fix the failing auth test and add an audit log call │ ┃ Otro Nombre r: refre │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Mine 0 ] [ Review 0 ] [ Merged 0 ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No pull requests here. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No GitHub token: run `gh auth login`, or set ⎿ 3 pass, 1 fail │ ┃ token in otro-nombre's plugin options │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /prs │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Otro Nombre
Otro Nombre r: refresh l: legend [ Mine 0 ] [ Review 0 ] [ Merged 0 ] No pull requests here. No GitHub token: run `gh auth login`, or set the GitHub token in otro-nombre's plugin options
README

Otro Nombre

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.

Requirements

  • Claude Code in a terminal session (the install command is not available in the desktop app's Code tab; once installed at user scope, the mod loads there too).
  • A GitHub token, either:
  • the GitHub CLI logged in (gh auth login); the mod runs gh auth token, or
  • a token in the mod's GitHub token setting, used only when gh auth token gives none. It needs the repo and read:org scopes.
  • macOS, to open pull requests in the browser (the mod runs open <url>).

Installation

At the prompt of a Claude Code terminal session, type:

/plugin install otro-nombre --marketplace escorponox/otro-nombre

Then:

  1. Answer y to add the github:escorponox/otro-nombre marketplace.
  2. Choose a scope; the user scope, first in the list, loads the mod in every session.
  3. Set the options (see Settings) or keep the defaults.

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.

Usage

The panel

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

TabKeyWhat it lists
Mine1Your open pull requests, with their Readiness, from Draft to Ready to merge
Review2Open, 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
Merged3Your 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).

Alerts

While the session is open, a toast tells you when one of your pull requests:

  • needs you: its Turn became Mine, a new Review request included;
  • became Ready to merge;
  • reached production;
  • failed CI after merging;
  • got an Auto-approval, for example #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.

Actions

Move the focus to a pull request with Tab, the arrow keys or a click. The focused pull request's actions get hotkeys:

ActionKeyWhere
Copy linkcEvery tab
Squash mergemMine, when Ready to merge
Update branchuMine, when the branch is behind its base
ClosexMine
ApprovepReview
Unblock analysisnMine
Review analysisnReview

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.

Auto-approval

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:

  • its checks pass (a commit with no checks never does);
  • no one has requested changes;
  • it arrived after you turned the team to [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.

Analyses

An analysis runs Claude in the background, with read-only tools, on one pull request:

  • Review analysis (Review tab): what the change does, its risks and what to look at.
  • Unblock analysis (Mine tab): why the pull request is not ready to merge and how to fix it.

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.

Shipment of merged pull requests

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.

Settings

Set them on install, or later with claude plugin configure otro-nombre@otro-nombre, which also shows the ones left unset.

SettingValuesDefaultWhat it does
Stale thresholdoff, 2w, 1m, 3m, 6m1mMarks pull requests older than this as stale
Review windowoff, 1w, 2w, 1m2wLists Review requests opened within this window
Merged windowoff, 1d, 3d, 1w3dLists your pull requests merged within this window in the Merged tab
Hidden organisationsloginsnoneOrganisations or users whose pull requests the panel never shows
Show private repositoriesyes / noyesShows pull requests in private repositories
Alertsyes / noyesRaises a toast when a pull request needs you, becomes Ready to merge, reaches production or fails CI after merging
GitHub tokentokennoneUsed only when gh auth token gives none

Development

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.

Source 37 files
hooks/register.tsx 15 lines
1import 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}
15
hooks/actions.ts 64 lines
1import { 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}
64
hooks/analyses.ts 147 lines
1import { 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
147
hooks/options.ts 31 lines
1import 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}
31
hooks/pane.tsx 319 lines
1import { 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}
319
hooks/refresh.ts 701 lines
1import { 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}
701
hooks/domain/element-key.ts 15 lines
1/**
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}
15
hooks/domain/format.ts 22 lines
1/** 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}
22
hooks/snapshot.ts 26 lines
1import 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}
26
hooks/domain/analysis.ts 152 lines
1import 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}
152
hooks/domain/age.ts 17 lines
1const 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}
17
hooks/domain/review-window.ts 13 lines
1const 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