SLOPSHOPPER

ship-it

🚒 One press raises a PR for the session's work, then watches CI and reviews and works through the feedback until it's ready to merge. Tracks every other PR…

newpanebandspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview Β· a replayed session in a sandbox
claude Β· ~/work/app Β· ship-it
β”‚ ┃ 🚒 Linked PRs βœ• β€Ί fix the failing auth test and add an audit log call β”‚ ┃ No other linked PRs. Paste a PR link, or run β”‚ ┃ /prs add <link|#n>. ⏺ Read(src/auth.ts) β”‚ ⎿ Read 6 lines β”‚ ⏺ Update(src/auth.ts) β”‚ ⎿ Added 2 lines, removed 1 line β”‚ ⏺ Bash(bun test) β”‚ ⎿ 3 pass, 1 fail β”‚ β”‚ ● Done. refresh now rejects expired claims and logs an audit event. β”‚ β”‚ ✻ Worked for 42s Β· done 4:20 PM β”‚ β”‚ β€Ί /ship-it β”‚ ⎿ ship-it: 🚒 Over to Claude to raise the PR β”‚ β”‚ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── β€Ί ⟨Claude Code's own drawing⟩ 🚒 ship it

Draws

Pane · 🚒 Linked PRs
No other linked PRs. Paste a PR link, or run /prs add <link|#n>.
Pane Β· ship-it
🚒 Ship It Not watching a PR. Press 🚒 ship it to raise one.
Prompt hint
⟨Claude Code's own drawing⟩ 🚒 ship it
README

🚒 Ship It

One press raises a PR for the session's work, then stays on it: it watches CI and reviews, works through the feedback, replies on every thread and tells you when the PR is ready to merge. It also keeps an eye on every other PR the session links, without touching them.

/plugin install ship-it@claude-desk-neighbours

Needs the GitHub CLI, signed in (gh auth login).

How it works

Click 🚒 ship it in the status row under the prompt, or run /ship-it.

  1. Raise. Claude checks the branch, offers to make one if you're on the default branch, stages only the files it changed by explicit path, commits, pushes and opens the PR with gh pr create, following the repo's PR template. It asks before pushing and before opening the PR.
  2. Watch. Ship It looks at the PR every minute with gh, with no model calls. It waits longer, up to eight minutes, while nothing changes. The status row shows where things are: 🚒 #46 Β· CI ⏳, 🚒 #46 Β· 2 comments, 🚒 #46 Β· βœ… ready.
  3. Answer. When a review thread, comment, review, failing check or merge conflict comes in, Ship It hands it to Claude in a new turn once the session is free. Claude decides whether each one is right, and pushes back with reasons where it isn't. It fixes what's valid, runs the checks for what it changed, commits and pushes, then replies on each thread with what changed or why not. It resolves the threads it fixed and leaves the ones it disagreed with open for the reviewer. For failing CI it reads the logs, fixes real failures, reruns a flaky one once, and reports failures unrelated to the PR without touching them.
  4. Done. When checks pass, reviews are approved and no threads are open, a line above the prompt and a notification say #46 is ready to merge. Ship It doesn't merge unless you turn that on.

Each round ends with a line above the prompt, like 🚒 #46 · fixed 2, pushed back on 1 · CI green, with Open PR, Details and Dismiss. Press the status-row item, or run /ship-it again, to open the pane: every check and open item, and what Claude did about each. From there you can pause, stop, or give it another round.

Replies are posted with your gh login and end with πŸ€– via Ship It, so reviewers can tell.

With What's Next installed, a briefing written while Ship It is watching a PR includes the PR and what's open on it, and Ship It keeps watching after the fresh start. So does a plain /clear.

Linked PRs

Any other PR the session touches gets tracked too, read-only:

  • a PR Claude creates or changes with gh (Ship It reads the engine's own record of the command, not its output);
  • a PR link you paste into a prompt;
  • the checked-out branch's open PR when the session starts;
  • one you add with /prs add <link|#n>. Remove one with /prs remove <link|#n>.

Each one gets a single glyph in the status row, next to 🚒 ship it, showing whose move it is, the most urgent state first:

GlyphMeans
βš”οΈconflicts with its base
❌CI failing
πŸ“changes requested
πŸ’¬2review threads waiting on a person (bots and Ship It's own replies don't count)
βœ…ready to merge
⏳CI running
πŸ‘€awaiting review
🟒open, nothing pending
🟣 ⚫merged, closed

The row shows the three most urgent, like #51 ❌ Β· #53 βœ… +1. Merged and closed PRs stay there until you've opened the pane once. The band above the prompt says something only when a PR changes in a way you'd want to know about: CI turns red, changes are requested, someone comments, it conflicts, it becomes ready, or it's merged or closed. Changes to several PRs share one line, which clears when you send your next prompt. Red CI, ready and merged also send a notification. The first look at a newly linked PR, and anything still pending, say nothing.

Press the chips, or run /prs, to open the pane: one row per PR with its title, CI passed/total, review state, open threads and how long since it changed. β€’ marks the ones that changed since you last looked. Tab to a row and press Enter to choose it, then o opens it in the browser, x stops tracking it, r looks again now, and s hands it to Ship It to see through, when Ship It isn't already on one.

Guardrails

  • While Ship It is watching a PR, it refuses force-pushes, amends, rebases and hard resets, and refuses to resolve a review thread it didn't hand to Claude.
  • It stops handing feedback to Claude after five rounds per PR and hands the PR back to you. Bots that re-review on every push get two rounds; their later comments wait for you.
  • One round at a time: new feedback waits until Claude's current round has ended.

Settings

SettingDefault
Confirm before pushing and replyingon
Check the PR every (seconds)60
Automatic rounds per PR5
Merge when readyoff

In the background

  • Polls with gh pr view and gh api graphql while a PR is being watched, and stops when it's merged, closed or you press Stop.
  • Looks at all linked PRs in a single gh api graphql call on the same schedule, waiting up to eight times longer while nothing changes. Polling doesn't back off while CI is running, and runs again shortly after a push. It stops once every linked PR is merged or closed.
  • Each round starts a normal turn on your session's model. Ship It makes no model calls of its own.
  • Keeps the PR it's watching, and the linked PRs, in the plugin's store, keyed by session, so /resume and /clear pick them back up. Entries older than 14 days are removed when a session starts.
Source 2 files
hooks/register.tsx 1186 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderNode } from 'claude-code'
3
4import type { Board, Check, Did, Item, Outcome, PrKind, Ship, TrackSource, Tracked } from '../types'
5
6const SHIP = { plugin: 'ship-it', key: 'ship' } as const
7const ship = atom(SHIP, null)
8const BOARD = { plugin: 'ship-it', key: 'board' } as const
9const EMPTY: Board = { rows: [], banner: null, bannerPr: null, selected: null, seenAt: 0 }
10const board = atom(BOARD, EMPTY)
11
12const COMMAND = 'ship-it'
13const PANE = 'ship-it'
14const TITLE = '🚒 Ship It'
15const PRS_COMMAND = 'prs'
16const PRS_PANE = 'ship-it-prs'
17const PRS_TITLE = '🚒 Linked PRs'
18const REPORT = 'mcp__ship-it__report'
19
20/** Ends every reply Claude posts, so the next poll knows the reply is its own. */
21export const MARKER = 'πŸ€– via Ship It'
22
23// Bots that re-review on every push would keep a round going forever, so their items get fewer rounds.
24const BOT_ROUNDS = 2
25// Turns that may end while raising before Ship It gives up on finding a PR.
26const RAISE_TURNS = 3
27const MAX_BACKOFF = 3
28const AFTER_TURN_MS = 10_000
29const BODY_CHARS = 1_200
30const KEEP_DAYS = 14
31// Tracked PRs: the most kept, the most shown in the status row, and how soon to look after a push.
32const MAX_TRACKED = 8
33const MAX_CHIPS = 3
34const AFTER_PUSH_MS = 15_000
35
36const FAILED = ['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR']
37const PENDING = ['PENDING', 'EXPECTED', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'REQUESTED']
38
39type Settings = { isConfirming: boolean; pollMs: number; maxRounds: number; canMerge: boolean }
40
41let settings: Settings = { isConfirming: true, pollMs: 60_000, maxRounds: 5, canMerge: false }
42
43/** One entry of `gh pr view --json statusCheckRollup`: a check run or a commit status. */
44export type Rollup = {
45  __typename?: string
46  name?: string
47  context?: string
48  status?: string
49  conclusion?: string
50  state?: string
51  detailsUrl?: string
52  targetUrl?: string
53}
54
55export type PrView = {
56  number: number
57  url: string
58  state: 'OPEN' | 'MERGED' | 'CLOSED'
59  isDraft: boolean
60  mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
61  reviewDecision: string | null
62  headRefOid: string
63  statusCheckRollup: Rollup[] | null
64}
65
66type Author = { login: string; __typename: string } | null
67type Comment = { id: string; author: Author; body: string; url: string }
68
69export type Talk = {
70  threads: { id: string; isResolved: boolean; path: string; line: number | null; comments: { nodes: Comment[] } }[]
71  comments: Comment[]
72  reviews: (Comment & { state: string })[]
73}
74
75export function checksOf(rollup: readonly Rollup[]): Check[] {
76  return rollup.map(r => {
77    const word = r.__typename === 'StatusContext' ? r.state : r.status === 'COMPLETED' ? r.conclusion : (r.status ?? 'PENDING')
78    const state = FAILED.includes(word ?? '') ? 'fail' : word === undefined || PENDING.includes(word) ? 'pending' : 'pass'
79    return { name: r.name ?? r.context ?? 'check', state, url: r.detailsUrl ?? r.targetUrl ?? null }
80  })
81}
82
83const isMine = (body: string) => body.includes(MARKER)
84const cut = (body: string) => (body.length > BODY_CHARS ? `${body.slice(0, BODY_CHARS)}…` : body)
85const who = (author: Author) => ({ author: author?.login ?? 'ghost', isBot: author?.__typename === 'Bot' })
86
87/** Everything open on the PR. A thread's id carries its latest comment that isn't Claude's own reply, so a
88 * thread Claude answered comes back only when someone answers it. */
89export function itemsOf(view: PrView, talk: Talk, checks: readonly Check[]): Item[] {
90  const items: Item[] = []
91  for (const thread of talk.threads) {
92    const last = thread.comments.nodes.filter(c => !isMine(c.body)).at(-1)
93    if (thread.isResolved || last === undefined) continue
94    items.push({ id: `thread:${thread.id}:${last.id}`, kind: 'thread', ...who(last.author), body: cut(last.body), url: last.url, threadId: thread.id, path: thread.path, line: thread.line })
95  }
96  for (const review of talk.reviews) {
97    if (isMine(review.body) || review.body.trim() === '' || !['CHANGES_REQUESTED', 'COMMENTED'].includes(review.state)) continue
98    items.push({ id: `review:${review.id}`, kind: 'review', ...who(review.author), body: cut(review.body), url: review.url })
99  }
100  for (const comment of talk.comments) {
101    if (isMine(comment.body)) continue
102    items.push({ id: `comment:${comment.id}`, kind: 'comment', ...who(comment.author), body: cut(comment.body), url: comment.url })
103  }
104  for (const check of checks.filter(c => c.state === 'fail')) {
105    items.push({ id: `check:${check.name}:${view.headRefOid}`, kind: 'check', author: 'CI', isBot: false, body: `${check.name} failed`, url: check.url })
106  }
107  if (view.mergeable === 'CONFLICTING') {
108    items.push({ id: `conflict:${view.headRefOid}`, kind: 'conflict', author: 'git', isBot: false, body: 'The branch conflicts with its base.', url: view.url })
109  }
110  return items
111}
112
113export function isReady(view: PrView, talk: Talk, checks: readonly Check[]): boolean {
114  return (
115    view.state === 'OPEN' &&
116    !view.isDraft &&
117    view.mergeable === 'MERGEABLE' &&
118    checks.every(c => c.state === 'pass') &&
119    view.reviewDecision !== 'CHANGES_REQUESTED' &&
120    view.reviewDecision !== 'REVIEW_REQUIRED' &&
121    talk.threads.every(t => t.isResolved)
122  )
123}
124
125/** A git command that rewrites history or force-pushes. */
126export function isRewrite(command: string): boolean {
127  return (
128    /\bgit\b[^\n;&|]*\bpush\b[^\n;&|]*(\s--force(-with-lease|-if-includes)?\b|\s-[a-zA-Z]*f[a-zA-Z]*\b|\s\+\S)/.test(command) ||
129    /\bgit\b[^\n;&|]*\b(rebase|filter-branch|filter-repo)\b/.test(command) ||
130    /\bgit\b[^\n;&|]*\bcommit\b[^\n;&|]*--amend\b/.test(command) ||
131    /\bgit\b[^\n;&|]*\breset\b[^\n;&|]*--hard\b/.test(command)
132  )
133}
134
135/** The review threads a command resolves. */
136export function resolvedIn(command: string): string[] {
137  return command.includes('resolveReviewThread') ? [...command.matchAll(/PRRT_[A-Za-z0-9_-]+/g)].map(m => m[0]) : []
138}
139
140const handedThreads = (s: Ship) => s.handled.filter(id => id.startsWith('thread:')).map(id => id.split(':')[1])
141
142export function ciWord(checks: readonly Check[]): string {
143  if (checks.some(c => c.state === 'fail')) return 'CI failing'
144  if (checks.some(c => c.state === 'pending')) return 'CI running'
145  return checks.length === 0 ? 'no CI' : 'CI green'
146}
147
148/** The status row's label. */
149export function labelOf(s: Ship | null): string {
150  if (s === null || s.phase === 'closed') return '🚒 ship it'
151  if (s.phase === 'raising') return '🚒 raising PR…'
152  const pr = `🚒 #${s.number}`
153  if (s.phase === 'paused') return `${pr} · ⏸ paused`
154  if (s.phase === 'ready') return `${pr} Β· βœ… ready`
155  const talking = s.open.filter(i => i.kind !== 'check' && i.kind !== 'conflict').length
156  if (talking > 0) return `${pr} Β· ${talking} comment${talking === 1 ? '' : 's'}`
157  if (s.checks.some(c => c.state === 'fail')) return `${pr} · CI ❌`
158  if (s.checks.some(c => c.state === 'pending')) return `${pr} · CI ⏳`
159  return s.phase === 'handed-back' ? `${pr} Β· over to you` : `${pr} Β· watching`
160}
161
162/** "fixed 2, pushed back on 1". */
163export function tally(outcomes: readonly Outcome[]): string {
164  const words: Record<Did, string> = { fixed: 'fixed', 'pushed-back': 'pushed back on', rerun: 'reran', reported: 'reported', skipped: 'skipped' }
165  const parts = (Object.keys(words) as Did[]).flatMap(did => {
166    const n = outcomes.filter(o => o.did === did).length
167    return n === 0 ? [] : [`${words[did]} ${n}`]
168  })
169  return parts.length === 0 ? 'nothing to change' : parts.join(', ')
170}
171
172function confirmLine(what: string): string {
173  return settings.isConfirming ? `Before ${what}, ask me with AskUserQuestion and wait for my answer.` : ''
174}
175
176export function raisePrompt(): string {
177  return [
178    'Raise a pull request for the work in this session. Ship It will watch it afterwards, so do only these steps:',
179    '1. Check `git status`, the current branch and the default branch (`gh repo view --json defaultBranchRef`).',
180    'If I am on the default branch, do not commit there: ask me with AskUserQuestion whether to create a branch, suggesting a name.',
181    '2. Stage only the files you changed for this work, each by explicit path. Never `git add -A` or `git add .`.',
182    'Commit with a message that follows the repository\'s conventions.',
183    '3. Push with `git push -u origin <branch>`. Never force-push.',
184    '4. If the branch already has an open PR, stop there. Otherwise open one with `gh pr create`, following the',
185    'repository\'s pull request template and title conventions if it has them.',
186    confirmLine('committing and pushing, and again before opening the PR,'),
187  ]
188    .filter(line => line !== '')
189    .join(' ')
190}
191
192function describe(item: Item): string {
193  const where = item.path !== undefined ? ` on ${item.path}${item.line != null ? `:${item.line}` : ''}` : ''
194  const thread = item.threadId !== undefined ? ` (thread ${item.threadId})` : ''
195  return [`### ${item.id}`, `${item.kind} from ${item.author}${item.isBot ? ' (bot)' : ''}${where}${thread}${item.url !== null ? ` ${item.url}` : ''}`, item.body].join('\n')
196}
197
198export function addressPrompt(s: Ship, items: readonly Item[]): string {
199  const rules = [
200    `New activity on PR #${s.number} (${s.url}). Work through each item below.`,
201    'For each one, decide critically whether it is right. Do not just comply: where it is wrong, out of scope, or',
202    'conflicts with this repository\'s standards, push back with your reasons instead of changing the code.',
203    'Fix what is valid, run the checks scoped to what you changed, commit the files by explicit path and push.',
204    'Never force-push, amend, rebase or reset: Ship It refuses those.',
205    `Reply on every comment and thread: what changed (with the commit) or why not. End each reply with "\\n\\n<sub>${MARKER}</sub>".`,
206    'Reply to a review thread with the `addPullRequestReviewThreadReply` GraphQL mutation via `gh api graphql`,',
207    `and to a comment or review with \`gh pr comment ${s.number}\`.`,
208    'Resolve a thread with `resolveReviewThread` only when you fixed it; leave threads you pushed back on open for the reviewer.',
209    'For a failing check, read its logs (`gh run view <run> --log-failed`). Fix real failures. If it is clearly flaky,',
210    'rerun it once (`gh run rerun <run> --failed`). Report failures unrelated to this PR without touching them.',
211    'For a conflict with the base branch, merge the base into this branch (never rebase), resolve it and push.',
212    s.hasReplied ? confirmLine('pushing') : confirmLine('pushing, and before posting the first replies on this PR,'),
213    `When done, call the \`${REPORT}\` tool (load it with ToolSearch if needed) with one outcome per item id.`,
214  ].filter(line => line !== '')
215  return [rules.join(' '), '', ...items.map(describe)].join('\n\n')
216}
217
218const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
219  repository(owner: $owner, name: $name) {
220    pullRequest(number: $number) {
221      reviewThreads(first: 100) { nodes { id isResolved path line comments(last: 20) { nodes { id body url author { login __typename } } } } }
222      comments(last: 50) { nodes { id body url author { login __typename } } }
223      reviews(last: 50) { nodes { id state body url author { login __typename } } }
224    }
225  }
226}`
227
228/** With `isPartial`, a failed call's output is still read: `gh api graphql` exits 1 when any part of a query
229 * fails, but prints the `data` it did resolve. */
230async function gh($: EngineInterface, args: readonly string[], isPartial = false): Promise<unknown> {
231  const done = await $.process.run(['gh', ...args], { timeoutMs: 60_000 }).catch(() => null)
232  if (done === null || (done.exitCode !== 0 && !isPartial)) return null
233  try {
234    return JSON.parse(done.stdout) as unknown
235  } catch {
236    return null
237  }
238}
239
240const VIEW_FIELDS = 'number,url,state,isDraft,mergeable,reviewDecision,headRefOid,statusCheckRollup'
241
242async function look($: EngineInterface, repo: string, n: number): Promise<{ view: PrView; talk: Talk } | null> {
243  const view = (await gh($, ['pr', 'view', String(n), '-R', repo, '--json', VIEW_FIELDS])) as PrView | null
244  const [owner = '', name = ''] = repo.split('/')
245  const found = (await gh($, ['api', 'graphql', '-f', `query=${QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${n}`])) as {
246    data?: { repository?: { pullRequest?: { reviewThreads: { nodes: Talk['threads'] }; comments: { nodes: Comment[] }; reviews: { nodes: Talk['reviews'] } } } }
247  } | null
248  const pr = found?.data?.repository?.pullRequest
249  if (view === null || pr === undefined) return null
250  return { view, talk: { threads: pr.reviewThreads.nodes, comments: pr.comments.nodes, reviews: pr.reviews.nodes } }
251}
252
253/** `https://github.com/owner/name/pull/46` β†’ `owner/name`. */
254export function repoOf(url: string): string | null {
255  const match = /github\.com\/([^/]+\/[^/]+)\/pull\/\d+/.exec(url)
256  return match?.[1] ?? null
257}
258
259async function storeKey($: EngineInterface): Promise<string> {
260  return `ship:${await $.session.id()}`
261}
262
263async function change($: EngineInterface, fn: (s: Ship) => Ship | null): Promise<Ship | null> {
264  await update($, ship, s => (s === null ? null : fn(s)))
265  const now = await read($, ship)
266  const key = await storeKey($)
267  await (now === null ? $.store.delete(key) : $.store.set(key, now))
268  return now
269}
270
271async function begin($: EngineInterface, s: Ship | null): Promise<void> {
272  await $.state.set(SHIP, s)
273  const key = await storeKey($)
274  await (s === null ? $.store.delete(key) : $.store.set(key, s))
275}
276
277async function tell($: EngineInterface, text: string): Promise<void> {
278  const sent = await $.ui.notify(text, { title: TITLE }).catch(() => null)
279  if (sent?.isSent !== true) $.ui.toast(`🚒 ${text}`)
280}
281
282/** What What's Next's briefing should carry, so the fresh session knows the PR Ship It goes on watching. */
283export function briefingNote(s: Ship): string {
284  const open = s.open.map(i => `- ${i.kind} from ${i.author}${i.path !== undefined ? ` on ${i.path}` : ''}: ${i.body.split('\n')[0]}`)
285  return [
286    `Ship It is watching PR #${s.number} (${s.url}) for this work: ${labelOf(s).replace('🚒 ', '')}.`,
287    open.length === 0 ? 'Nothing is open on it.' : `Open on it:\n${open.join('\n')}`,
288    'Put the PR number, its URL and these open items in the briefing\'s current state, and say Ship It will hand new feedback to the new session.',
289  ].join('\n')
290}
291
292// Linked PRs: every other PR the session links is tracked without acting on it. One glyph each in the status
293// row, a band line when one changes in a way that needs you, and a row each in the /prs pane.
294
295type PrRef = { repo: string; number: number; url: string }
296
297export const prId = (pr: { repo: string; number: number }): string => `${pr.repo}#${pr.number}`
298
299/** Every PR link in a piece of text, once each. */
300export function prsIn(text: string): PrRef[] {
301  const found = new Map<string, PrRef>()
302  for (const m of text.matchAll(/https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g)) {
303    const pr = { repo: m[1] ?? '', number: Number(m[2]), url: m[0] }
304    found.set(prId(pr), pr)
305  }
306  return [...found.values()]
307}
308
309/** `git@github.com:me/app.git` or `https://github.com/me/app` β†’ `me/app`. */
310export function repoOfRemote(remote: string | null | undefined): string | null {
311  const match = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(remote ?? '')
312  return match?.[1] ?? null
313}
314
315/** A PR named as a link, `owner/name#12`, `#12` or `12`; the bare forms in `here`. */
316export function parseRef(ref: string, here: string | null): PrRef | null {
317  const linked = prsIn(ref)[0]
318  if (linked !== undefined) return linked
319  const match = /^(?:([\w.-]+\/[\w.-]+))?#?(\d+)$/.exec(ref.trim())
320  const repo = match?.[1] ?? here
321  if (match === null || repo === null) return null
322  const number = Number(match[2])
323  return { repo, number, url: `https://github.com/${repo}/pull/${number}` }
324}
325
326/** What one look at a PR found. */
327export type Seen = Pick<Tracked, 'isDraft' | 'checks' | 'reviewDecision' | 'threads'> & { state: string; mergeable: string; title: string }
328
329/** The PR's most urgent state: whose move it is. */
330export function kindOf(pr: Seen): PrKind {
331  if (pr.state === 'MERGED') return 'merged'
332  if (pr.state === 'CLOSED') return 'closed'
333  if (pr.mergeable === 'CONFLICTING') return 'conflict'
334  if (pr.checks.some(c => c.state === 'fail')) return 'fail'
335  if (pr.reviewDecision === 'CHANGES_REQUESTED') return 'changes'
336  if (pr.threads.human > 0) return 'threads'
337  if (pr.checks.some(c => c.state === 'pending')) return 'pending'
338  if (pr.reviewDecision === 'REVIEW_REQUIRED') return 'review'
339  if (!pr.isDraft && pr.mergeable === 'MERGEABLE' && pr.threads.bot === 0) return 'ready'
340  return 'open'
341}
342
343const KIND_GLYPH: Record<PrKind, string> = {
344  merged: '🟣',
345  closed: '⚫',
346  conflict: 'βš”οΈ',
347  fail: '❌',
348  changes: 'πŸ“',
349  threads: 'πŸ’¬',
350  pending: '⏳',
351  review: 'πŸ‘€',
352  ready: 'βœ…',
353  open: '🟒',
354}
355const KIND_WORD: Record<PrKind, string> = {
356  merged: 'merged',
357  closed: 'closed',
358  conflict: 'conflicts with its base',
359  fail: 'CI failing',
360  changes: 'changes requested',
361  threads: 'comments open',
362  pending: 'CI running',
363  review: 'awaiting review',
364  ready: 'ready to merge',
365  open: 'open',
366}
367// The status row shows the most urgent PRs first: the ones that are your move.
368const URGENCY: readonly PrKind[] = ['conflict', 'fail', 'changes', 'threads', 'ready', 'pending', 'review', 'open', 'merged', 'closed']
369// Changes that are worth a band line, and the ones also worth a notification.
370const NEWS: readonly PrKind[] = ['merged', 'closed', 'conflict', 'fail', 'changes', 'ready']
371const LOUD: readonly PrKind[] = ['merged', 'fail', 'ready']
372
373export function glyphOf(row: Tracked): string {
374  if (row.kind === null) return '…'
375  return row.kind === 'threads' ? `πŸ’¬${row.threads.human}` : KIND_GLYPH[row.kind]
376}
377
378const rank = (row: Tracked) => (row.kind === null ? URGENCY.length : URGENCY.indexOf(row.kind))
379const isDone = (row: Tracked) => row.kind === 'merged' || row.kind === 'closed'
380
381/** The status row's chips, most urgent first; a merged or closed PR only until the pane is opened. */
382export function trackLabel(b: Board): string | null {
383  const shown = b.rows.filter(r => !isDone(r) || r.changedAt > b.seenAt).sort((x, y) => rank(x) - rank(y))
384  if (shown.length === 0) return null
385  const chips = shown.slice(0, MAX_CHIPS).map(r => `#${r.number} ${glyphOf(r)}`)
386  const more = shown.length - MAX_CHIPS
387  return `${chips.join(' Β· ')}${more > 0 ? ` +${more}` : ''}`
388}
389
390/** A band line for what changed between two looks, or null. The first look says nothing. */
391export function newsOf(before: Tracked, after: Tracked): string | null {
392  if (before.kind === null || after.kind === null) return null
393  const pr = `#${after.number}`
394  if (after.kind !== before.kind && NEWS.includes(after.kind)) {
395    if (after.kind !== 'fail') return `${pr} ${KIND_WORD[after.kind]}`
396    const failed = after.checks.filter(c => c.state === 'fail').map(c => c.name)
397    return `${pr} CI failed (${failed.slice(0, 2).join(', ')}${failed.length > 2 ? ', …' : ''})`
398  }
399  const more = after.threads.human - before.threads.human
400  return more > 0 && !isDone(after) ? `${pr} ${more} new comment${more === 1 ? '' : 's'}` : null
401}
402
403/** Drops the oldest merged or closed rows first, then the oldest, down to the cap. */
404export function capped(rows: readonly Tracked[]): Tracked[] {
405  const kept = [...rows]
406  while (kept.length > MAX_TRACKED) {
407    const done = kept.findIndex(isDone)
408    kept.splice(done === -1 ? 0 : done, 1)
409  }
410  return kept
411}
412
413const PR_FIELDS = `number url title state isDraft mergeable reviewDecision
414      commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { __typename ... on CheckRun { name status conclusion detailsUrl } ... on StatusContext { context state targetUrl } } } } } } }
415      reviewThreads(first: 100) { nodes { isResolved comments(last: 1) { nodes { body author { __typename } } } } }`
416
417/** One query for every row, each PR under its own alias. */
418export function boardQuery(rows: readonly { repo: string; number: number }[]): string {
419  const parts = rows.map((r, i) => {
420    const [owner = '', name = ''] = r.repo.split('/')
421    return `  p${i}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {\n    pullRequest(number: ${r.number}) {\n      ${PR_FIELDS}\n    }\n  }`
422  })
423  return `query {\n${parts.join('\n')}\n}`
424}
425
426export type PrNode = {
427  number: number
428  url: string
429  title: string
430  state: string
431  isDraft: boolean
432  mergeable: string
433  reviewDecision: string | null
434  commits: { nodes: { commit: { statusCheckRollup: { contexts: { nodes: Rollup[] } } | null } }[] }
435  reviewThreads: { nodes: { isResolved: boolean; comments: { nodes: { body: string; author: { __typename: string } | null }[] } }[] }
436}
437
438/** An open thread whose last word is a bot's, or Claude's own reply, isn't waiting on a person. */
439export function seenOf(node: PrNode): Seen {
440  const last = node.reviewThreads.nodes.filter(t => !t.isResolved).map(t => t.comments.nodes.at(-1))
441  const bot = last.filter(c => c === undefined || c.author?.__typename === 'Bot' || isMine(c.body)).length
442  return {
443    title: node.title,
444    state: node.state,
445    mergeable: node.mergeable,
446    isDraft: node.isDraft,
447    reviewDecision: node.reviewDecision,
448    checks: checksOf(node.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []),
449    threads: { human: last.length - bot, bot },
450  }
451}
452
453/** What What's Next's briefing should carry about the tracked PRs. */
454export function boardNote(rows: readonly Tracked[]): string {
455  return [
456    'Ship It also tracks these PRs linked to this session, without acting on them:',
457    ...rows.map(r => `- ${r.url}${r.title !== null ? ` "${r.title}"` : ''}: ${r.kind === null ? 'not looked at yet' : KIND_WORD[r.kind]}`),
458    'List them in the briefing\'s current state.',
459  ].join('\n')
460}
461
462function ago(ms: number): string {
463  const minutes = Math.floor(ms / 60_000)
464  if (minutes < 1) return 'now'
465  if (minutes < 60) return `${minutes}m`
466  const hours = Math.floor(minutes / 60)
467  return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`
468}
469
470/** One pane row: glyph, number, title, then CI, review, threads and how long since it changed. */
471export function rowText(row: Tracked, now: number, isNew: boolean, columns: number): string {
472  const passed = row.checks.filter(c => c.state === 'pass').length
473  const ci = row.checks.length === 0 ? 'no CI' : `CI ${passed}/${row.checks.length}`
474  const review = row.reviewDecision === 'APPROVED' ? 'approved' : row.reviewDecision === 'CHANGES_REQUESTED' ? 'changes' : row.reviewDecision === 'REVIEW_REQUIRED' ? 'review' : ''
475  const threads = row.threads.human + row.threads.bot === 0 ? '' : `πŸ’¬ ${row.threads.human}${row.threads.bot > 0 ? ` (+${row.threads.bot} bot)` : ''}`
476  const tail = [row.kind === null ? 'looking…' : ci, review, threads, row.isDraft ? 'draft' : '', ago(now - row.changedAt)].filter(p => p !== '').join('  ')
477  const head = `${isNew ? 'β€’' : ' '} ${glyphOf(row)} #${row.number}  `
478  const room = Math.max(8, columns - head.length - tail.length - 4)
479  const title = row.title ?? row.repo
480  return `${head}${title.length > room ? `${title.slice(0, room - 1)}…` : title.padEnd(room)}  ${tail}`
481}
482
483let boardTimer: { cancel: () => void } | null = null
484let boardPrint = ''
485let boardQuiet = 0
486// The pane's dots mark rows changed since the open before this one; none after a reload.
487let dotsSince = Number.POSITIVE_INFINITY
488// The rows tracked when `/clear` ran, kept until the new session starts.
489let carriedBoard: Board | null = null
490
491async function boardKey($: EngineInterface): Promise<string> {
492  return `board:${await $.session.id()}`
493}
494
495async function changeBoard($: EngineInterface, fn: (b: Board) => Board): Promise<Board> {
496  await update($, board, fn)
497  const now = await read($, board)
498  const key = await boardKey($)
499  await (now.rows.length === 0 ? $.store.delete(key) : $.store.set(key, now))
500  return now
501}
502
503function stopBoard(): void {
504  boardTimer?.cancel()
505  boardTimer = null
506}
507
508function scheduleBoard($: EngineInterface, ms: number): void {
509  stopBoard()
510  boardTimer = $.clock.after(ms, () => void pollBoard($).catch(() => scheduleBoard($, settings.pollMs)))
511}
512
513/** Adds PRs to the board, skipping the one Ship It drives and any already there. How many were new. */
514async function track($: EngineInterface, prs: readonly PrRef[], source: TrackSource): Promise<number> {
515  const s = await read($, ship)
516  // While raising, the branch's new PR is Ship It's own.
517  if (s?.phase === 'raising' && (source === 'session' || source === 'branch')) return 0
518  const driven = s !== null && s.phase !== 'closed' && s.repo !== null ? `${s.repo}#${s.number}` : null
519  const rows = (await read($, board)).rows
520  const fresh = prs.filter(pr => prId(pr) !== driven && !rows.some(r => prId(r) === prId(pr)))
521  if (fresh.length === 0) return 0
522  const at = await $.clock.now()
523  const added: Tracked[] = fresh.map(pr => ({
524    ...pr,
525    title: null,
526    source,
527    kind: null,
528    isDraft: false,
529    checks: [],
530    reviewDecision: null,
531    threads: { human: 0, bot: 0 },
532    addedAt: at,
533    changedAt: at,
534  }))
535  await changeBoard($, b => ({ ...b, rows: capped([...b.rows.filter(r => !added.some(a => prId(a) === prId(r))), ...added]) }))
536  scheduleBoard($, 1)
537  return fresh.length
538}
539
540async function untrack($: EngineInterface, id: string): Promise<boolean> {
541  const had = (await read($, board)).rows.some(r => prId(r) === id)
542  await changeBoard($, b => ({
543    ...b,
544    rows: b.rows.filter(r => prId(r) !== id),
545    selected: b.selected === id ? null : b.selected,
546    ...(b.bannerPr === id ? { banner: null, bannerPr: null } : {}),
547  }))
548  return had
549}
550
551async function pollBoard($: EngineInterface): Promise<void> {
552  const live = (await read($, board)).rows.filter(r => !isDone(r))
553  if (live.length === 0) return
554  const found = (await gh($, ['api', 'graphql', '-f', `query=${boardQuery(live)}`], true)) as { data?: Record<string, { pullRequest: PrNode | null } | null> } | null
555  const data = found?.data
556  if (data == null) return scheduleBoard($, settings.pollMs)
557  const at = await $.clock.now()
558  const looked = new Map<string, Tracked>()
559  const news: { line: string; id: string; kind: PrKind }[] = []
560  // A PR GitHub can't resolve (a wrong number, a private or deleted repo) never will, so it stops being tracked.
561  const lost: Tracked[] = []
562  live.forEach((row, i) => {
563    const node = data[`p${i}`]?.pullRequest
564    if (node == null) return void lost.push(row)
565    const look = seenOf(node)
566    const kind = kindOf(look)
567    const { title, isDraft, checks, reviewDecision, threads } = look
568    const changed = kind !== row.kind || threads.human !== row.threads.human
569    const seen: Tracked = { ...row, title, isDraft, checks, reviewDecision, threads, kind, changedAt: changed ? at : row.changedAt }
570    const line = newsOf(row, seen)
571    if (line !== null) news.push({ line, id: prId(row), kind })
572    looked.set(prId(row), seen)
573  })
574  const now = await changeBoard($, b => ({
575    ...b,
576    rows: b.rows.filter(r => !lost.some(l => prId(l) === prId(r))).map(r => looked.get(prId(r)) ?? r),
577    ...(news.length === 0 ? {} : { banner: news.map(n => n.line).join(' Β· '), bannerPr: news.length === 1 ? (news[0]?.id ?? null) : null }),
578  }))
579  if (lost.length > 0) $.ui.toast(`🚒 Can't see ${lost.map(r => prId(r)).join(', ')} on GitHub; stopped tracking ${lost.length === 1 ? 'it' : 'them'}`)
580  const loud = news.filter(n => LOUD.includes(n.kind))
581  if (loud.length > 0) await tell($, loud.map(n => n.line).join(' Β· '))
582
583  const print = JSON.stringify(now.rows.map(r => [prId(r), r.kind, r.threads, r.checks]))
584  boardQuiet = print === boardPrint ? boardQuiet + 1 : 0
585  boardPrint = print
586  if (now.rows.every(isDone)) return stopBoard()
587  // Running CI is about to say something, so it is looked at without backing off.
588  const isRunning = now.rows.some(r => !isDone(r) && r.checks.some(c => c.state === 'pending'))
589  scheduleBoard($, isRunning ? settings.pollMs : settings.pollMs * 2 ** Math.min(boardQuiet, MAX_BACKOFF))
590}
591
592/** After a Bash call: a PR it created or changed, or a push that could move a tracked PR's CI. */
593async function linked($: EngineInterface, done: unknown): Promise<void> {
594  type GitOperation = { pr?: { number: number; url?: string; action: string }; push?: { branch: string } }
595  const op = (done as { result?: { gitOperation?: GitOperation } } | null)?.result?.gitOperation
596  const rows = (await read($, board)).rows
597  if (op?.pr !== undefined) {
598    const { number, url, action } = op.pr
599    const repo = (url !== undefined ? repoOf(url) : null) ?? repoOfRemote((await $.session.repo())?.remote)
600    if (repo === null) return
601    if (rows.some(r => prId(r) === `${repo}#${number}`)) return scheduleBoard($, 1)
602    if (action !== 'merged' && action !== 'closed') await track($, [{ repo, number, url: url ?? `https://github.com/${repo}/pull/${number}` }], 'session')
603    return
604  }
605  if (op?.push !== undefined && rows.some(r => !isDone(r))) scheduleBoard($, AFTER_PUSH_MS)
606}
607
608/** The checked-out branch's open PR, at the start of a session. */
609async function trackBranch($: EngineInterface): Promise<void> {
610  const pr = (await gh($, ['pr', 'view', '--json', 'number,url,state'])) as { number: number; url: string; state: string } | null
611  const repo = pr === null ? null : repoOf(pr.url)
612  if (pr?.state === 'OPEN' && repo !== null) await track($, [{ repo, number: pr.number, url: pr.url }], 'branch')
613}
614
615async function loadBoard($: EngineInterface): Promise<void> {
616  stopBoard()
617  const saved = (await $.store.get(await boardKey($))) as Board | undefined
618  await $.state.set(BOARD, saved ?? EMPTY)
619  if (saved?.rows.some(r => !isDone(r)) === true) scheduleBoard($, 1)
620}
621
622async function openPrs($: EngineInterface): Promise<void> {
623  const at = await $.clock.now()
624  dotsSince = (await read($, board)).seenAt
625  await changeBoard($, b => ({ ...b, seenAt: at }))
626  await $.ui.open({ id: PRS_PANE, title: PRS_TITLE, focus: true, closeOnEscape: true, rows: 14 })
627}
628
629async function openTracked($: EngineInterface, id: string | null): Promise<void> {
630  const row = (await read($, board)).rows.find(r => prId(r) === id)
631  if (row === undefined) return
632  await $.process.run(['gh', 'pr', 'view', String(row.number), '-R', row.repo, '--web']).catch(() => null)
633}
634
635/** Hands a tracked PR to Ship It to see through, when it isn't already driving one. */
636async function promote($: EngineInterface, id: string): Promise<void> {
637  const s = await read($, ship)
638  const row = (await read($, board)).rows.find(r => prId(r) === id)
639  if ((s !== null && s.phase !== 'closed') || row === undefined || isDone(row)) return
640  await begin($, { ...fresh(await $.clock.now()), phase: 'watching', number: row.number, url: row.url, repo: row.repo, turn: null })
641  await untrack($, id)
642  schedule($, 1)
643  await $.ui.close({ id: PRS_PANE }).catch(() => undefined)
644  await openPane($)
645}
646
647// A PR watched when `/clear` ran, kept until the new session starts.
648let carried: Ship | null = null
649let timer: { cancel: () => void } | null = null
650let lastPrint = ''
651let quiet = 0
652
653function stopPolling(): void {
654  timer?.cancel()
655  timer = null
656}
657
658function schedule($: EngineInterface, ms: number): void {
659  stopPolling()
660  timer = $.clock.after(ms, () => void poll($).catch(() => schedule($, settings.pollMs)))
661}
662
663async function poll($: EngineInterface): Promise<void> {
664  const s = await read($, ship)
665  if (s === null || s.number === null || s.repo === null || s.phase === 'raising' || s.phase === 'closed') return
666  const seen = await look($, s.repo, s.number)
667  if (seen === null) return schedule($, settings.pollMs)
668  const { view, talk } = seen
669  const checks = checksOf(view.statusCheckRollup ?? [])
670  const open = itemsOf(view, talk, checks)
671  const print = JSON.stringify([view.state, view.reviewDecision, view.mergeable, checks, open.map(i => i.id)])
672  quiet = print === lastPrint ? quiet + 1 : 0
673  lastPrint = print
674
675  if (view.state !== 'OPEN') {
676    const word = view.state === 'MERGED' ? 'was merged' : 'was closed'
677    await change($, x => ({ ...x, phase: 'closed', checks, open, turn: null, banner: `#${x.number} ${word}` }))
678    stopPolling()
679    return tell($, `#${s.number} ${word}`)
680  }
681
682  const ready = isReady(view, talk, checks)
683  const now = await change($, x => {
684    const phase = ready && (x.phase === 'watching' || x.phase === 'handed-back') ? 'ready' : !ready && x.phase === 'ready' ? 'watching' : x.phase
685    return { ...x, phase, checks, open, banner: phase === 'ready' && x.phase !== 'ready' ? `#${x.number} is ready to merge` : x.banner }
686  })
687  if (now === null) return
688  if (now.phase === 'ready' && s.phase !== 'ready') {
689    await tell($, `#${now.number} is ready to merge`)
690    if (settings.canMerge && !now.isMergeAsked) await merge($, now)
691  }
692  await answer($, now)
693  schedule($, settings.pollMs * 2 ** Math.min(quiet, MAX_BACKOFF))
694}
695
696/** Hands new items to Claude, or hands the PR back once the rounds are used up. */
697async function answer($: EngineInterface, s: Ship): Promise<void> {
698  if ((s.phase !== 'watching' && s.phase !== 'ready') || s.turn !== null) return
699  const items = s.open.filter(i => !s.handled.includes(i.id) && (!i.isBot || s.botRounds < BOT_ROUNDS))
700  if (items.length === 0) return
701  if (s.rounds >= settings.maxRounds) {
702    if (s.phase === 'ready') return
703    await change($, x => ({ ...x, phase: 'handed-back', banner: `#${x.number}: ${settings.maxRounds} rounds done, over to you` }))
704    return tell($, `#${s.number}: ${settings.maxRounds} rounds done, over to you`)
705  }
706  const asked = await change($, x => ({
707    ...x,
708    phase: 'watching',
709    handled: [...x.handled, ...items.map(i => i.id)],
710    rounds: x.rounds + 1,
711    botRounds: x.botRounds + (items.some(i => i.isBot) ? 1 : 0),
712    turn: 'queued',
713  }))
714  if (asked === null) return
715  try {
716    await $.prompt.submit({ text: addressPrompt(asked, items) })
717    await change($, x => ({ ...x, turn: 'running' }))
718  } catch {
719    await change($, x => ({ ...x, turn: null }))
720  }
721}
722
723async function merge($: EngineInterface, s: Ship): Promise<void> {
724  if (s.repo === null || s.number === null) return
725  await change($, x => ({ ...x, isMergeAsked: true }))
726  const allowed = (await gh($, ['repo', 'view', s.repo, '--json', 'squashMergeAllowed,mergeCommitAllowed,rebaseMergeAllowed'])) as Record<string, boolean> | null
727  const method = allowed?.squashMergeAllowed !== false ? '--squash' : allowed.mergeCommitAllowed ? '--merge' : '--rebase'
728  const done = await $.process.run(['gh', 'pr', 'merge', String(s.number), '-R', s.repo, method]).catch(() => null)
729  await tell($, done?.exitCode === 0 ? `Merged #${s.number}` : `Could not merge #${s.number}: ${done?.stderr.trim() ?? 'gh failed'}`)
730}
731
732function fresh(at: number): Ship {
733  return {
734    phase: 'raising',
735    number: null,
736    url: null,
737    repo: null,
738    checks: [],
739    open: [],
740    handled: [],
741    outcomes: [],
742    rounds: 0,
743    botRounds: 0,
744    turn: 'queued',
745    raiseTurns: 0,
746    hasReplied: false,
747    isMergeAsked: false,
748    banner: null,
749    at,
750  }
751}
752
753async function raise($: EngineInterface): Promise<void> {
754  if ((await $.session.repo()) === null) return $.ui.toast('🚒 Ship It works inside a git repository')
755  const auth = await $.process.run(['gh', 'auth', 'status']).catch(() => null)
756  if (auth?.exitCode !== 0) return $.ui.toast('🚒 Ship It needs the GitHub CLI, signed in: gh auth login')
757  await begin($, fresh(await $.clock.now()))
758  try {
759    await $.prompt.submit({ text: raisePrompt() })
760    await change($, x => ({ ...x, turn: 'running' }))
761  } catch {
762    await begin($, null)
763  }
764}
765
766/** After a turn while raising: the branch's PR, once there is one. */
767async function findPr($: EngineInterface): Promise<void> {
768  const pr = (await gh($, ['pr', 'view', '--json', 'number,url,state'])) as { number: number; url: string; state: string } | null
769  const repo = pr === null ? null : repoOf(pr.url)
770  if (pr !== null && pr.state === 'OPEN' && repo !== null) {
771    await change($, x => ({ ...x, phase: 'watching', number: pr.number, url: pr.url, repo, turn: null }))
772    await untrack($, `${repo}#${pr.number}`)
773    return schedule($, 1)
774  }
775  const now = await change($, x => ({ ...x, turn: null, raiseTurns: x.raiseTurns + 1 }))
776  if (now !== null && now.raiseTurns >= RAISE_TURNS) {
777    await begin($, null)
778    $.ui.toast('🚒 No PR found for this branch; Ship It stopped')
779  }
780}
781
782async function press($: EngineInterface): Promise<void> {
783  const s = await read($, ship)
784  if (s === null || s.phase === 'closed') return raise($)
785  await openPane($)
786}
787
788async function openPane($: EngineInterface): Promise<void> {
789  await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true, rows: 20 })
790}
791
792async function openPr($: EngineInterface): Promise<void> {
793  const s = await read($, ship)
794  if (s?.number == null || s.repo === null) return
795  await $.process.run(['gh', 'pr', 'view', String(s.number), '-R', s.repo, '--web']).catch(() => null)
796}
797
798async function stop($: EngineInterface): Promise<void> {
799  stopPolling()
800  await begin($, null)
801  await $.ui.close({ id: PANE }).catch(() => undefined)
802}
803
804async function togglePause($: EngineInterface): Promise<void> {
805  const now = await change($, x => ({ ...x, phase: x.phase === 'paused' ? 'watching' : 'paused' }))
806  if (now?.phase === 'watching') schedule($, 1)
807}
808
809async function anotherRound($: EngineInterface): Promise<void> {
810  await change($, x => ({ ...x, phase: 'watching', rounds: 0, botRounds: 0, banner: null }))
811  schedule($, 1)
812}
813
814const DIDS: readonly Did[] = ['fixed', 'pushed-back', 'rerun', 'reported', 'skipped']
815
816export function outcomesOf(input: unknown): Outcome[] {
817  const list = (input as { outcomes?: unknown } | null)?.outcomes
818  if (!Array.isArray(list)) return []
819  return list.flatMap((o: unknown) => {
820    const { id, did, note } = (o ?? {}) as Record<string, unknown>
821    return typeof id === 'string' && DIDS.includes(did as Did) ? [{ id, did: did as Did, note: typeof note === 'string' ? note : '' }] : []
822  })
823}
824
825async function load($: EngineInterface): Promise<void> {
826  stopPolling()
827  const saved = (await $.store.get(await storeKey($))) as Ship | undefined
828  // A raise or a turn in flight did not survive the restart.
829  const s = saved === undefined || saved.phase === 'raising' ? null : { ...saved, turn: null }
830  await $.state.set(SHIP, s)
831  if (s !== null && s.phase !== 'closed') schedule($, 1)
832}
833
834async function prune($: EngineInterface): Promise<void> {
835  const cutoff = (await $.clock.now()) - KEEP_DAYS * 24 * 60 * 60 * 1000
836  for (const key of await $.store.keys()) {
837    if (key.startsWith('ship:')) {
838      const s = (await $.store.get(key)) as Ship | undefined
839      if ((s?.at ?? 0) < cutoff) await $.store.delete(key)
840    }
841    if (key.startsWith('board:')) {
842      const b = (await $.store.get(key)) as Board | undefined
843      if (Math.max(0, ...(b?.rows ?? []).map(r => r.changedAt)) < cutoff) await $.store.delete(key)
844    }
845  }
846}
847
848// The hint line under the prompt can be shared with other mods: items on a row keyed `desk-hint` are kept
849// in order by their keys, so the row reads the same in any load order.
850const DESK = 'desk-hint'
851
852function keyOf(node: RenderNode | undefined): string {
853  if (typeof node !== 'object' || node === null || !('props' in node)) return ''
854  const key = (node.props as Record<string, unknown> | undefined)?.key
855  return typeof key === 'string' ? key : ''
856}
857
858export function joinDesk(below: RenderElement, mine: RenderElement, wrap: (children: RenderNode[]) => RenderElement): RenderElement {
859  if (below.type !== 'Box' || keyOf(below) !== DESK) return wrap([below, mine])
860  const [line, ...items] = below.children ?? []
861  const sorted = [...items.filter(n => keyOf(n) !== keyOf(mine)), mine].sort((a, b) => keyOf(a).localeCompare(keyOf(b)))
862  return { ...below, children: line === undefined ? sorted : [line, ...sorted] }
863}
864
865// The engine draws the band's collapse mark `[-]` over the right end of its first row.
866const MARKER_CELLS = 4
867
868const CHECK_ICON = { pass: 'βœ…', pending: '⏳', fail: '❌' } as const
869const ITEM_ICON = { thread: 'πŸ’¬', comment: 'πŸ’¬', review: 'πŸ“', check: '❌', conflict: 'βš”οΈ' } as const
870const DID_WORD: Record<Did, string> = { fixed: 'fixed', 'pushed-back': 'pushed back', rerun: 'reran', reported: 'reported', skipped: 'skipped' }
871
872export const register: Register = (on, options) => {
873  settings = {
874    isConfirming: options.confirmOutward !== false,
875    pollMs: Math.max(15, Number(options.pollSeconds ?? 60)) * 1000,
876    maxRounds: Math.max(1, Number(options.maxRounds ?? 5)),
877    canMerge: options.allowAutoMerge === true,
878  }
879
880  on('session.start', async ($, e, next) => {
881    await $.command.register({ name: COMMAND, description: '🚒 Raise a PR for this work and see it through to mergeable' })
882    await $.command.register({ name: PRS_COMMAND, description: '🚒 Linked PRs: state, CI and feedback (add or remove <link|#n>)' })
883    await $.tool.register({
884      name: 'report',
885      description: 'Ship It: record what you did about each PR item it handed you, once you have replied and pushed.',
886      inputSchema: {
887        type: 'object',
888        properties: {
889          outcomes: {
890            type: 'array',
891            items: {
892              type: 'object',
893              properties: {
894                id: { type: 'string', description: 'The item id Ship It gave, e.g. thread:PRRT_x:PRRC_y' },
895                did: { type: 'string', enum: DIDS },
896                note: { type: 'string', description: 'One short line: what changed, or why not' },
897              },
898              required: ['id', 'did', 'note'],
899            },
900          },
901        },
902        required: ['outcomes'],
903      },
904    })
905    void prune($).catch(() => undefined)
906    if (e.isInteractive) {
907      void load($)
908        .then(() => loadBoard($))
909        .then(() => trackBranch($))
910        .catch(() => undefined)
911    }
912    return next(e)
913  })
914
915  // `/clear` and `/resume` go on under a new session id and fire no `session.start`. A clear keeps watching the
916  // same PR, which is what What's Next's fresh start needs; a resume picks up whatever that session watched.
917  on('classic.SessionStart', ($, e, next) => {
918    if (e.source === 'resume') {
919      void load($).catch(() => undefined)
920      void loadBoard($).catch(() => undefined)
921    }
922    if (e.source === 'clear' && carried !== null) {
923      const s = carried
924      carried = null
925      void begin($, s)
926        .then(() => schedule($, 1))
927        .catch(() => undefined)
928    }
929    if (e.source === 'clear' && carriedBoard !== null) {
930      const b = carriedBoard
931      carriedBoard = null
932      void changeBoard($, () => b)
933        .then(() => scheduleBoard($, 1))
934        .catch(() => undefined)
935    }
936    return next(e)
937  })
938
939  on('session.end', async ($, e, next) => {
940    if (e.reason === 'clear' || e.reason === 'resume') {
941      stopPolling()
942      const s = await read($, ship).catch(() => null)
943      carried = e.reason === 'clear' && s !== null && s.phase !== 'raising' && s.phase !== 'closed' ? { ...s, turn: null } : null
944      // A clear moves the watch to the new session; a resume leaves it stored for when this session is resumed.
945      await (e.reason === 'clear' ? begin($, null) : $.state.set(SHIP, null)).catch(() => undefined)
946
947      stopBoard()
948      const b = await read($, board).catch(() => EMPTY)
949      carriedBoard = e.reason === 'clear' && b.rows.some(r => !isDone(r)) ? { ...b, rows: b.rows.filter(r => !isDone(r)), banner: null, bannerPr: null } : null
950      await (e.reason === 'clear' ? changeBoard($, () => EMPTY) : $.state.set(BOARD, EMPTY)).catch(() => undefined)
951    }
952    return next(e)
953  })
954
955  on('model.fork', async ($, e, next) => {
956    if (next.origin.plugin !== 'whats-next') return next(e)
957    const s = await read($, ship)
958    const rows = (await read($, board)).rows.filter(r => !isDone(r))
959    const notes = [
960      ...(s === null || s.phase === 'raising' || s.phase === 'closed' ? [] : [briefingNote(s)]),
961      ...(rows.length === 0 ? [] : [boardNote(rows)]),
962    ]
963    return notes.length === 0 ? next(e) : next({ ...e, prompt: [e.prompt, ...notes].join('\n\n') })
964  })
965
966  on('command.run', { command: PRS_COMMAND }, async ($, e) => {
967    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
968    if (verb === 'add' || verb === 'remove') {
969      const pr = parseRef(rest.join(' '), repoOfRemote((await $.session.repo())?.remote))
970      if (pr === null) return { text: '🚒 Name a PR: a link, owner/name#12 or #12' }
971      if (verb === 'remove') return { text: (await untrack($, prId(pr))) ? `🚒 Stopped tracking #${pr.number}` : `🚒 Not tracking #${pr.number}` }
972      return { text: (await track($, [pr], 'manual')) > 0 ? `🚒 Tracking #${pr.number}` : `🚒 Already on #${pr.number}` }
973    }
974    await openPrs($)
975    return { text: '🚒 Linked PRs' }
976  })
977
978  on('command.run', { command: COMMAND }, async $ => {
979    const s = await read($, ship)
980    if (s !== null && s.phase !== 'closed') {
981      await openPane($)
982      return { text: `🚒 Watching #${s.number ?? '…'}` }
983    }
984    // A prompt submitted from this hook would wait on the turn the command holds, so it goes once the command is done.
985    $.clock.after(1, () => void raise($).catch(() => undefined))
986    return { text: '🚒 Over to Claude to raise the PR' }
987  })
988
989  on('turn.complete', ($, e, next) => {
990    if (e.agentId === undefined) {
991      void (async () => {
992        const s = await read($, ship)
993        if (s === null) return
994        if (s.phase === 'raising' && s.turn !== 'queued') return findPr($)
995        if (s.turn === 'running') {
996          await change($, x => ({ ...x, turn: null }))
997          schedule($, AFTER_TURN_MS)
998        }
999      })().catch(() => undefined)
1000    }
1001    return next(e)
1002  })
1003
1004  on('prompt.submit', ($, e, next) => {
1005    if (e.origin.kind !== 'plugin') {
1006      void change($, x => (x.banner === null ? x : { ...x, banner: null })).catch(() => undefined)
1007      void (async () => {
1008        if ((await read($, board)).banner !== null) await changeBoard($, b => ({ ...b, banner: null, bannerPr: null }))
1009        const pasted = prsIn(e.text)
1010        if (pasted.length > 0) await track($, pasted, 'pasted')
1011      })().catch(() => undefined)
1012    }
1013    return next(e)
1014  })
1015
1016  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1017    const done = await next(e)
1018    void linked($, done).catch(() => undefined)
1019    return done
1020  })
1021
1022  on('tool.call', { tool: REPORT }, async ($, e) => {
1023    const outcomes = outcomesOf(e)
1024    const now = await change($, x => ({
1025      ...x,
1026      hasReplied: true,
1027      outcomes: [...x.outcomes.filter(o => !outcomes.some(n => n.id === o.id)), ...outcomes],
1028      banner: `#${x.number} Β· ${tally(outcomes)} Β· ${ciWord(x.checks)}`,
1029    }))
1030    return { result: now === null ? 'Ship It is not watching a PR.' : `Recorded ${outcomes.length} outcome${outcomes.length === 1 ? '' : 's'}.` }
1031  })
1032
1033  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1034    const s = await read($, ship)
1035    if (s === null || s.phase === 'closed') return next(e)
1036    if (isRewrite(e.command)) return { deny: 'Ship It: no force-pushes or history rewrites while it watches a PR. Make a new commit instead.' }
1037    const foreign = resolvedIn(e.command).filter(id => !handedThreads(s).includes(id))
1038    if (foreign.length > 0) return { deny: `Ship It: ${foreign.join(', ')} was not handed to you, so leave it for its reviewer.` }
1039    return next(e)
1040  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'Ship It: its guard failed, so the command did not run.' }))
1041
1042  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
1043    const below = await next(e)
1044    const s = await read($, ship)
1045    const { Box, Button } = $.ui.resolve(e)
1046    const wrap = (children: RenderNode[]) => (
1047      <Box key={DESK} flexDirection="row" columnGap={2}>
1048        {children}
1049      </Box>
1050    )
1051    const row = joinDesk(below, <Button key="desk-4-ship-it" plain dimColor label={labelOf(s)} onPress={() => void press($)} />, wrap)
1052    const chips = trackLabel(await read($, board))
1053    return chips === null ? row : joinDesk(row, <Button key="desk-5-ship-it-prs" plain dimColor label={chips} onPress={() => void openPrs($)} />, wrap)
1054  })
1055
1056  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1057    const s = await read($, ship)
1058    const b = await read($, board)
1059    if (e.props.hasSurvey || (s?.banner == null && b.banner === null)) return next(e)
1060    const below = await next(e)
1061    const { Box, Text, Button } = $.ui.resolve(e)
1062    return (
1063      <Box flexDirection="column">
1064        {below}
1065        {s?.banner != null && (
1066          <Box flexDirection="row" flexWrap="wrap" columnGap={2} width={e.props.bodyColumns - MARKER_CELLS}>
1067            <Box flexShrink={1}>
1068              <Text>🚒 {s.banner}</Text>
1069            </Box>
1070            <Box flexDirection="row" columnGap={2} flexShrink={0}>
1071              {s.number !== null && <Button key="band-pr" plain label="Open PR" onPress={() => void openPr($)} />}
1072              <Button key="band-details" plain label="Details" onPress={() => void openPane($)} />
1073              <Button key="band-dismiss" plain dimColor label="Dismiss" onPress={() => void change($, x => ({ ...x, banner: null }))} />
1074            </Box>
1075          </Box>
1076        )}
1077        {b.banner !== null && (
1078          <Box flexDirection="row" flexWrap="wrap" columnGap={2} width={e.props.bodyColumns - MARKER_CELLS}>
1079            <Box flexShrink={1}>
1080              <Text>🚒 {b.banner}</Text>
1081            </Box>
1082            <Box flexDirection="row" columnGap={2} flexShrink={0}>
1083              {b.bannerPr !== null && <Button key="track-pr" plain label="Open PR" onPress={() => void openTracked($, b.bannerPr)} />}
1084              <Button key="track-details" plain label="Details" onPress={() => void openPrs($)} />
1085              <Button key="track-dismiss" plain dimColor label="Dismiss" onPress={() => void changeBoard($, x => ({ ...x, banner: null, bannerPr: null }))} />
1086            </Box>
1087          </Box>
1088        )}
1089      </Box>
1090    )
1091  })
1092
1093  on('ui.render', { component: 'Pane', requestId: PRS_PANE }, async ($, e) => {
1094    const s = await read($, ship)
1095    const b = await read($, board)
1096    const at = await $.clock.now()
1097    const { Box, Text, Button } = $.ui.resolve(e)
1098    const isDriving = s !== null && s.phase !== 'closed' && s.phase !== 'raising'
1099    const chosen = b.rows.find(r => prId(r) === b.selected) ?? b.rows[0]
1100    return (
1101      <Box flexDirection="column">
1102        {isDriving && (
1103          <Text dimColor wrap="truncate-end">
1104            {'  '}
1105            {labelOf(s)} Β· Ship It is seeing this one through
1106          </Text>
1107        )}
1108        {b.rows.length === 0 && <Text dimColor>No other linked PRs. Paste a PR link, or run /prs add &lt;link|#n&gt;.</Text>}
1109        {b.rows.map(r => (
1110          <Button key={`row-${prId(r)}`} plain dimColor={r !== chosen} onPress={() => void changeBoard($, x => ({ ...x, selected: prId(r) }))}>
1111            {rowText(r, at, r.changedAt > dotsSince, e.props.bodyColumns)}
1112          </Button>
1113        ))}
1114        {chosen !== undefined && (
1115          <Box flexDirection="row" columnGap={2} marginTop={1}>
1116            <Text dimColor>#{chosen.number}:</Text>
1117            <Button key="prs-open" hotkey="o" plain label="Open" autoFocus onPress={() => void openTracked($, prId(chosen))} />
1118            {!isDriving && !isDone(chosen) && <Button key="prs-ship" hotkey="s" plain label="Ship it" onPress={() => void promote($, prId(chosen))} />}
1119            <Button key="prs-untrack" hotkey="x" plain label="Untrack" onPress={() => void untrack($, prId(chosen))} />
1120            <Button key="prs-refresh" hotkey="r" plain label="Refresh" onPress={() => scheduleBoard($, 1)} />
1121          </Box>
1122        )}
1123      </Box>
1124    )
1125  })
1126
1127  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1128    const s = await read($, ship)
1129    const { Box, Text, Button } = $.ui.resolve(e)
1130    if (s === null || s.phase === 'closed') {
1131      return (
1132        <Box flexDirection="column">
1133          <Text bold>🚒 Ship It</Text>
1134          <Text dimColor>{s === null ? 'Not watching a PR. Press 🚒 ship it to raise one.' : `${s.banner ?? 'Done.'}`}</Text>
1135        </Box>
1136      )
1137    }
1138    const outcome = (id: string) => s.outcomes.find(o => o.id === id)
1139    const talking = s.open.filter(i => i.kind !== 'check')
1140    return (
1141      <Box flexDirection="column">
1142        <Box flexDirection="row" columnGap={1} marginBottom={1}>
1143          <Text bold>{labelOf(s)}</Text>
1144          <Text dimColor>
1145            Β· round {s.rounds} of {settings.maxRounds}
1146          </Text>
1147        </Box>
1148        <Text bold>Checks</Text>
1149        {s.checks.length === 0 && <Text dimColor>  none yet</Text>}
1150        {s.checks.map(c => (
1151          <Text key={`check-${c.name}`} wrap="truncate-end">
1152            {'  '}
1153            {CHECK_ICON[c.state]} {c.name}
1154          </Text>
1155        ))}
1156        <Box marginTop={1}>
1157          <Text bold>Open</Text>
1158        </Box>
1159        {talking.length === 0 && <Text dimColor>  nothing open</Text>}
1160        {talking.map(item => {
1161          const did = outcome(item.id)
1162          const waiting = s.handled.includes(item.id) ? (did === undefined ? 'with Claude' : `${DID_WORD[did.did]}: ${did.note}`) : 'not handed over yet'
1163          return (
1164            <Box key={`item-${item.id}`} flexDirection="column">
1165              <Text wrap="truncate-end">
1166                {'  '}
1167                {ITEM_ICON[item.kind]} {item.author}
1168                {item.path !== undefined ? ` Β· ${item.path}` : ''} Β· {item.body.split('\n')[0]}
1169              </Text>
1170              <Text dimColor wrap="truncate-end">
1171                {'     '}β†’ {waiting}
1172              </Text>
1173            </Box>
1174          )
1175        })}
1176        <Box flexDirection="row" columnGap={2} marginTop={1}>
1177          <Button key="act-pr" hotkey="o" plain label="Open PR" autoFocus onPress={() => void openPr($)} />
1178          <Button key="act-pause" hotkey="p" plain label={s.phase === 'paused' ? 'Resume' : 'Pause'} onPress={() => void togglePause($)} />
1179          {s.phase === 'handed-back' && <Button key="act-round" hotkey="r" plain label="Another round" onPress={() => void anotherRound($)} />}
1180          <Button key="act-stop" hotkey="s" plain label="Stop" onPress={() => void stop($)} />
1181        </Box>
1182      </Box>
1183    )
1184  })
1185}
1186
types/index.d.ts 108 lines
1export type CheckState = 'pass' | 'pending' | 'fail'
2
3/** One CI check on the PR's head commit. */
4export type Check = { name: string; state: CheckState; url: string | null }
5
6export type ItemKind = 'thread' | 'comment' | 'review' | 'check' | 'conflict'
7
8/** Something on the PR that needs an answer: a review thread, a comment, a review, a failing check or a conflict. */
9export type Item = {
10  /** Changes when there is something new to answer: a thread's id carries its latest comment's. */
11  id: string
12  kind: ItemKind
13  author: string
14  isBot: boolean
15  /** The text to answer, cut short. */
16  body: string
17  url: string | null
18  /** The review thread's node id, for replying and resolving. */
19  threadId?: string
20  path?: string
21  line?: number | null
22}
23
24export type Did = 'fixed' | 'pushed-back' | 'rerun' | 'reported' | 'skipped'
25
26/** What Claude did about one item, as it reported it. */
27export type Outcome = { id: string; did: Did; note: string }
28
29/**
30 * `raising`: Claude is opening the PR. `watching`: polling and answering. `paused`: polling, answering nothing.
31 * `handed-back`: out of automatic rounds. `ready`: mergeable. `closed`: merged or closed, nothing more to do.
32 */
33export type Phase = 'raising' | 'watching' | 'paused' | 'handed-back' | 'ready' | 'closed'
34
35/** The PR Ship It is seeing through, kept per session. */
36export type Ship = {
37  phase: Phase
38  number: number | null
39  url: string | null
40  /** `owner/name`. */
41  repo: string | null
42  checks: Check[]
43  /** What is open on the PR now, answered or not. */
44  open: Item[]
45  /** Item ids already handed to Claude. */
46  handled: string[]
47  outcomes: Outcome[]
48  rounds: number
49  botRounds: number
50  /** A turn Ship It asked for: queued until it starts, running until it ends. */
51  turn: 'queued' | 'running' | null
52  /** Turns that ended while raising without a PR to show for it. */
53  raiseTurns: number
54  hasReplied: boolean
55  isMergeAsked: boolean
56  /** The band line, until dismissed or the next prompt. */
57  banner: string | null
58  at: number
59}
60
61/** How a tracked PR came to be linked to the session. */
62export type TrackSource = 'session' | 'pasted' | 'branch' | 'manual'
63
64/** A tracked PR's most urgent state, the one its glyph shows. */
65export type PrKind = 'merged' | 'closed' | 'conflict' | 'fail' | 'changes' | 'threads' | 'pending' | 'review' | 'ready' | 'open'
66
67/** A PR the session linked, tracked without acting on it. */
68export type Tracked = {
69  /** `owner/name`. */
70  repo: string
71  number: number
72  url: string
73  title: string | null
74  source: TrackSource
75  /** Null until the first look. */
76  kind: PrKind | null
77  isDraft: boolean
78  checks: Check[]
79  reviewDecision: string | null
80  /** Unresolved review threads, by who spoke last. */
81  threads: { human: number; bot: number }
82  addedAt: number
83  /** When `kind` or the thread count last changed. */
84  changedAt: number
85}
86
87/** Every PR the session tracks besides the one Ship It drives, kept per session. */
88export type Board = {
89  rows: Tracked[]
90  /** The band line, until dismissed or the next prompt. */
91  banner: string | null
92  /** The PR the band line is about, `owner/name#n`. */
93  bannerPr: string | null
94  /** The pane's chosen row, `owner/name#n`. */
95  selected: string | null
96  /** When the pane was last opened: rows changed since show a dot. */
97  seenAt: number
98}
99
100declare module 'claude-code' {
101  interface PluginState {
102    'ship-it': {
103      ship: Ship | null
104      board: Board
105    }
106  }
107}
108