π’ 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β¦

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).
Click π’ ship it in the status row under the prompt, or run /ship-it.
gh pr create, following the repo's PR template. It asks before pushing and before opening the PR.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.#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.
Any other PR the session touches gets tracked too, read-only:
gh (Ship It reads the engine's own record of the command, not its output);/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:
| Glyph | Means |
|---|---|
| βοΈ | conflicts with its base |
| β | CI failing |
| π | changes requested |
| π¬2 | review 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.
| Setting | Default |
|---|---|
| Confirm before pushing and replying | on |
| Check the PR every (seconds) | 60 |
| Automatic rounds per PR | 5 |
| Merge when ready | off |
gh pr view and gh api graphql while a PR is being watched, and stops when it's merged, closed or you press Stop.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./resume and /clear pick them back up. Entries older than 14 days are removed when a session starts.hooks/register.tsx 1186 lines1import { 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 <link|#n>.</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}
1186types/index.d.ts 108 lines1export 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