PR Radar pane: every GitHub PR tied to your account, sorted by who must act (blocked, your turn, ready, waiting, draft), with the Jira status and priority, CI…

PR Radar is a Claude Code mod. It adds a pane that lists every open GitHub pull request tied to your account and sorts them into lanes by who must act next. Each card shows the review state, CI, open review threads, the Jira status and priority, the linked Claude sessions, and unfinished Agent Context runs. Its buttons start the next step.
It runs in Claude Code: the terminal CLI, the Code tab of the desktop app, and the VS Code extension. It does not run in claude.ai chat or Cowork, and claude -p or cloud sessions draw no pane.
/plugin marketplace add WBLD4A/pr-radar
/plugin install pr-radar@pr-radar
Or from a shell:
claude plugin marketplace add WBLD4A/pr-radar
claude plugin install pr-radar@pr-radar
Then run /reload-plugins in open sessions.
The pane opens on its own at the start of every session, without taking focus. /prs reopens it, and /prs refresh refreshes it right away. While the pane is closed, a button above the prompt reopens it. The model reads the same data through the mod's snapshot tool, so you can ask it things like "what should I do next?".
The pane has seven lanes, in this order:
Inside a lane, cards are sorted by Jira priority, then by most recent update. A Jira status that means blocked or on hold moves the pull request to that lane. Below the lanes, the pane lists the Jira issues assigned to you that are in progress but have no pull request yet.
What a card's buttons do:
On your own pull requests, Mark ready marks a draft ready for review, and Back to draft turns a pull request in the Ready for manual review lane back into a draft. Both run gh pr ready. A mark lasts until the pull request next changes on GitHub.
A new conversation opens as a desktop session when the app offers one. Otherwise it opens in a new Terminal tab with a PowerShell command, a fallback that works only on Windows. If neither is possible, the prompt goes into the composer.
gh CLI, signed in. PR Radar lists your pull requests through gh api graphql, which costs a few points of your account's hourly GraphQL budget per refresh.python on PATH. The scripts in scripts/ find your local checkouts and read run records./prs refresh does ask.logs/**/runs/*.json) note unfinished agent work. PR Radar reads them to list that work. Without one, that section stays empty.A mod runs with your own permissions and is not sandboxed. PR Radar is read-only except for the buttons you press. Everything it does is listed here.
Processes it starts:
gh api graphql, gh api user and gh api rate_limit, to list your pull requests and read your GraphQL budget;gh pr ready (with --undo for Back to draft), only when you press Mark ready or Back to draft;git -C <folder> remote get-url origin, to match the Claude sessions to their repositories;python -I scripts/runs.py and python -I scripts/checkouts.py, the two read-only scripts in this repository;Files it reads:
<agentContextPath>/logs/**/runs/*.json;.git/config of the folders one level below your home folder and its Documents, Documents/GitHub, source/repos and repos folders, to find each repository's local checkout.Network calls: only GitHub through gh, and Jira through the Atlassian connector of your session when you have one. It sends no data anywhere else, and stores its snapshot, marks and lease in the plugin's own store.
Tools of the session it calls: the Atlassian connector's JQL search and site list, and the desktop app's session list, start-session, send-message and Terminal tools. A call that would ask for a permission runs only when you refresh with /prs refresh or press Authorize.
callTool in hooks/register.tsx): only the session tools named above, found by these fixed name suffixes: __searchJiraIssuesUsingJql, __getAccessibleAtlassianResources, ccd_session_mgmt__list_sessions, __start_session, ccd_session_mgmt__send_message and terminal__run_in_terminal. The Jira and session-list calls run on each refresh. The other three run only when you press a card's button to start or continue a conversation. The tool name comes from the session's tool list, which is why it is not written as fixed text; no server answer ever picks a tool or a command.gh api graphql command (graphql in hooks/register.tsx): the program and its flags are fixed text. The query is a constant built in hooks/model.ts, and its $q1…$q4 variables are GitHub search strings made from your login (author:<login>, review-requested:<login>, and so on); the detail query takes only the pull request ids the first one returned. Only GitHub's answer is read; it is parsed as data and never run.USERPROFILE or HOME, to find your home folder, and CLAUDE_CODE_EXECPATH, to start claude in the Terminal fallback. The mod reads no token or key: gh uses its own sign-in, and nothing read from the environment is sent anywhere.tool.call hook is on its own mcp__pr-radar__snapshot tool. It answers that tool with the radar's summary in place of a server, as a mod-registered tool does. It does not see or change any other tool call, and sends no conversation text out.command.run hook handles only the mod's own /prs command: it opens the pane, and with refresh refreshes it.Set these in the plugin's config menu. Each option is one row there.
| Option | Default | Effect |
|---|---|---|
agentContextPath | empty, meaning ~/agent-context | The Agent Context checkout whose run records are read. |
refreshMinutes | 5 | The minutes between two automatic refreshes. Open sessions share one snapshot. |
claude plugin disable pr-radar@pr-radar
claude plugin enable pr-radar@pr-radar
Then run /reload-plugins in open sessions.
The mod's entry point is hooks/register.tsx, and its pure logic lives in hooks/model.ts. Run the tests and the manifest check with:
claude plugin test .
claude plugin validate .
See the privacy policy.
hooks/register.tsx 1108 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode } from 'claude-code'
3
4import type { Filter, JiraInfo, Lane, Marks, PrRow, RunInfo, SessionInfo, Snapshot, SourceState } from '../types'
5import {
6 DETAIL_QUERY,
7 ID_QUERY,
8 LANES,
9 LOW_BUDGET,
10 actionPrompt,
11 ago,
12 applyJira,
13 applyMarks,
14 collectPrs,
15 draftToggle,
16 idsOf,
17 isRateLimited,
18 laneCounts,
19 lonelyIssues as workingIssues,
20 newlyUrgent,
21 parseJiraPage,
22 parseSessions,
23 pauseNote,
24 priorityMark,
25 repoOfRemote,
26 runsOf,
27 searches,
28 sessionsOf,
29 sortPrs,
30 statusText,
31 summary,
32 withDetails,
33} from './model'
34import type { RawDetails, RawIds } from './model'
35
36const PANE = 'pr-radar'
37const TITLE = 'PR Radar'
38const STORE_KEY = 'snapshot-v5'
39const TICK_MS = 60_000
40const PAUSE_KEY = 'githubPause'
41const LEASE_KEY = 'refreshLease'
42const LEASE_MS = 120_000
43
44const snapshotAtom = atom({ plugin: 'pr-radar', key: 'snapshot' } as const, null)
45const refreshingAtom = atom({ plugin: 'pr-radar', key: 'isRefreshing' } as const, false)
46const filterAtom = atom({ plugin: 'pr-radar', key: 'filter' } as const, 'all')
47const foldedAtom = atom({ plugin: 'pr-radar', key: 'folded' } as const, ['draft', 'onhold'])
48const clockAtom = atom({ plugin: 'pr-radar', key: 'clock' } as const, 0)
49const paneOpenAtom = atom({ plugin: 'pr-radar', key: 'isPaneOpen' } as const, false)
50
51type Options = { agentContextPath?: string; refreshMinutes?: number }
52
53/** GitHub refreshes paused until `until`: the budget is spent, or only low. */
54type Pause = { until: number; isSpent: boolean }
55
56/** The GitHub GraphQL points left, and when they are restored. */
57type Budget = { remaining: number; resetAt: string } | null
58
59const FILTERS: { id: Filter; label: string }[] = [
60 { id: 'all', label: 'All' },
61 { id: 'todo', label: 'To do' },
62 { id: 'mine', label: 'My PRs' },
63 { id: 'reviews', label: 'Reviews' },
64]
65
66const matchesFilter = (row: PrRow, filter: Filter): boolean => {
67 if (filter === 'mine') return row.role === 'author'
68 if (filter === 'reviews') return row.role !== 'author'
69 if (filter === 'todo') return ['blocked', 'action', 'inprogress', 'ready'].includes(row.lane)
70 return true
71}
72
73const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error))
74
75const isTimeout = (error: unknown): boolean => /still running after|timed? ?out/i.test(errorText(error))
76
77/**
78 * The connector tool whose name ends with `suffix`, among those the session
79 * lists. When several servers offer it, the first one the permission rules
80 * allow, so that an allow rule written for one of them applies.
81 * @param suffix - The end of the tool's name, after its server.
82 * @returns The tool's full name, or null when the session lists none.
83 */
84const findTool = async ($: EngineInterface, suffix: string): Promise<string | null> => {
85 try {
86 const names = (await $.tool.list()).map(tool => tool.name).filter(name => name.endsWith(suffix))
87 if (names.length < 2) return names[0] ?? null
88 for (const name of names) {
89 const { decision } = await $.tool.check({ tool: name, input: {} }).catch(() => ({ decision: 'ask' }))
90 if (decision === 'allow') return name
91 }
92 return names[0] ?? null
93 } catch {
94 return null
95 }
96}
97
98/**
99 * How a refresh was raised: `isAttended` when the person asked for it, and
100 * their own words for the press in `consent` when a permission may be at stake.
101 */
102type Asked = { isAttended: boolean; consent?: string }
103
104/**
105 * Calls an MCP tool and returns its text. Unattended (a timer), a call the
106 * permission check would ask about is skipped: no dialog pops up on its own.
107 * Attended, the person's `consent` goes with the call.
108 * @param tool - The tool's full name.
109 * @param input - The tool's arguments.
110 * @param asked - Whether the person asked for the refresh, and their consent to pass with the call.
111 * @returns The tool's text, or the source state (needs-permission or error)
112 * with its reason when the call is skipped, refused or fails.
113 */
114const callTool = async (
115 $: EngineInterface,
116 tool: string,
117 input: Record<string, unknown>,
118 asked: Asked,
119): Promise<{ text: string } | { state: SourceState; reason: string }> => {
120 const args = {
121 tool: tool as `mcp__${string}__${string}`,
122 ...(asked.isAttended && asked.consent !== undefined ? { consent: asked.consent } : {}),
123 ...input,
124 }
125 if (!asked.isAttended) {
126 const { decision } = await $.tool.check({ tool: args.tool, input })
127 if (decision !== 'allow') return { state: 'needs-permission', reason: `${tool} needs a permission` }
128 }
129 const ran = await $.tool.call(args)
130 if (ran.deny !== undefined) return { state: 'needs-permission', reason: ran.deny }
131 if (ran.isError === true) return { state: 'error', reason: (ran.text ?? '').slice(0, 200) }
132 return { text: ran.text ?? '' }
133}
134
135/** Runs one `gh api graphql` query and returns its `data`; a GraphQL or `gh` error throws. */
136const graphql = async ($: EngineInterface, query: string, fields: string[]): Promise<unknown> => {
137 const ran = await $.process.run(['gh', 'api', 'graphql', '-f', `query=${query}`, ...fields.flatMap(field => ['-f', field])], {
138 timeoutMs: 60_000,
139 })
140 if (ran.exitCode !== 0) throw new Error(`gh api graphql: ${ran.stderr.trim().slice(0, 300)}`)
141 const body = JSON.parse(ran.stdout) as { data?: unknown; errors?: { message: string }[] }
142 if (body.data === undefined || body.data === null) {
143 throw new Error(body.errors?.map(one => one.message).join('; ') ?? 'empty response')
144 }
145 return body.data
146}
147
148/**
149 * The open PRs of the account in two queries: their ids, then the details of
150 * those found, which also tell the GraphQL budget left.
151 */
152const fetchPrs = async ($: EngineInterface): Promise<{ login: string; prs: PrRow[]; budget: Budget }> => {
153 let login = (await $.store.get('login')) as string | undefined
154 if (login === undefined) {
155 const who = await $.process.run(['gh', 'api', 'user', '--jq', '.login'])
156 if (who.exitCode !== 0) throw new Error(`gh api user: ${who.stderr.trim().slice(0, 200)}`)
157 login = who.stdout.trim()
158 await $.store.set('login', login)
159 }
160 const [q1, q2, q3, q4] = searches(login)
161 const raw = (await graphql($, ID_QUERY, [`q1=${q1}`, `q2=${q2}`, `q3=${q3}`, `q4=${q4}`])) as RawIds
162 const ids = idsOf(raw)
163 if (ids.length === 0) return { login: raw.viewer.login, prs: [], budget: null }
164 const details = (await graphql($, DETAIL_QUERY, ids.map(id => `ids[]=${id}`))) as RawDetails
165 return { login: raw.viewer.login, prs: collectPrs(withDetails(raw, details.nodes)), budget: details.rateLimit }
166}
167
168/** When the GraphQL budget is restored, from `gh api rate_limit`, which costs none; 15 minutes when unknown. */
169const resetOf = async ($: EngineInterface, now: number): Promise<number> => {
170 try {
171 const ran = await $.process.run(['gh', 'api', 'rate_limit', '--jq', '.resources.graphql.reset'])
172 const at = Number(ran.stdout.trim()) * 1000
173 if (ran.exitCode === 0 && at > now) return at
174 } catch {
175 // The default below.
176 }
177 return now + 15 * 60_000
178}
179
180/**
181 * Pauses the automatic GitHub refreshes until the reset when the budget left
182 * is low, and returns the note that says so; otherwise lifts the pause.
183 */
184const notePause = async ($: EngineInterface, budget: Budget, now: number): Promise<string | undefined> => {
185 const until = budget === null ? Number.NaN : Date.parse(budget.resetAt)
186 if (budget !== null && budget.remaining < LOW_BUDGET && until > now) {
187 await $.store.set(PAUSE_KEY, { until, isSpent: false } satisfies Pause)
188 return pauseNote(now, until, false)
189 }
190 if ((await $.store.get(PAUSE_KEY)) !== undefined) await $.store.delete(PAUSE_KEY)
191 return undefined
192}
193
194const fetchRuns = async ($: EngineInterface, options: Options): Promise<RunInfo[]> => {
195 let root = options.agentContextPath?.trim() ?? ''
196 if (root === '') {
197 root = `${await homeOf($)}/agent-context`
198 }
199 const script = `${$.plugin.root}/scripts/runs.py`
200 const ran = await $.process.run(['python', '-I', script, '--root', root, '--days', '30'], { timeoutMs: 120_000 })
201 if (ran.exitCode !== 0) throw new Error(`run records: ${ran.stderr.trim().slice(0, 200)}`)
202 return JSON.parse(ran.stdout) as RunInfo[]
203}
204
205type JiraResult = {
206 jira: Record<string, JiraInfo>
207 inProgress: JiraInfo[]
208 site: string | null
209 state: SourceState
210 error?: string
211}
212
213const JIRA_FIELDS = ['summary', 'status', 'priority', 'assignee', 'issuetype']
214
215/** What the pane says when the Atlassian connector's answer cannot be read. */
216const JIRA_UNREADABLE = "the Atlassian connector's answer could not be read"
217
218/**
219 * The issues asked per search call. An issue weighs about 1.7 KB in the
220 * connector's answer; past the MCP output limit (30 issues did), the engine
221 * hands back a notice that the answer was saved to a file instead of the JSON.
222 */
223const JIRA_PAGE = 10
224
225/**
226 * Runs one JQL search page by page, so that each answer stays small enough to
227 * come back whole.
228 * @param search - The search tool's full name.
229 * @param input - The search's arguments, without `maxResults` and `nextPageToken`.
230 * @param limit - The most issues to read, over all pages.
231 * @param asked - How the refresh was raised, for the permission of each call.
232 * @returns The issues read, or the source state and its reason when a call is
233 * skipped, refused or fails.
234 * @throws When an answer cannot be read.
235 */
236const searchJira = async (
237 $: EngineInterface,
238 search: string,
239 input: Record<string, unknown>,
240 limit: number,
241 asked: Asked,
242): Promise<{ issues: JiraInfo[] } | { state: SourceState; reason: string }> => {
243 const issues: JiraInfo[] = []
244 let token: string | null = null
245 do {
246 const page = { ...input, maxResults: Math.min(JIRA_PAGE, limit - issues.length), ...(token === null ? {} : { nextPageToken: token }) }
247 const got = await callTool($, search, page, asked)
248 if (!('text' in got)) return got
249 const read = parseJiraPage(got.text)
250 issues.push(...read.issues)
251 // A page without issues ends the search even when it names a next one.
252 token = read.issues.length === 0 ? null : read.next
253 } while (token !== null && issues.length < limit)
254 return { issues }
255}
256
257/**
258 * Reads the Jira issues of the PRs and those assigned to me in progress.
259 * @param keys - The Jira keys of the PRs.
260 * @param asked - How the refresh was raised, for the permission of each call.
261 * @returns The issues found, the site, and whether Jira could be read.
262 */
263const fetchJira = async ($: EngineInterface, keys: string[], asked: Asked): Promise<JiraResult> => {
264 const empty: JiraResult = { jira: {}, inProgress: [], site: null, state: 'unavailable' }
265 const search = await findTool($, '__searchJiraIssuesUsingJql')
266 if (search === null) return empty
267 let site = (await $.store.get('jiraSite')) as { cloudId: string; url: string } | undefined
268 if (site === undefined) {
269 const resources = await findTool($, '__getAccessibleAtlassianResources')
270 if (resources === null) return empty
271 const got = await callTool($, resources, {}, asked)
272 if (!('text' in got)) return { ...empty, state: got.state, error: got.reason }
273 const body = JSON.parse(got.text) as { data?: { resources?: { cloudId: string; url: string }[] } }
274 const first = body.data?.resources?.[0]
275 if (first === undefined) return { ...empty, state: 'error', error: 'no Atlassian site' }
276 site = { cloudId: first.cloudId, url: first.url }
277 await $.store.set('jiraSite', site)
278 }
279 const jira: Record<string, JiraInfo> = {}
280 if (keys.length > 0) {
281 const got = await searchJira($, search, { cloudId: site.cloudId, jql: `key in (${keys.join(', ')})`, fields: JIRA_FIELDS }, 100, asked)
282 if (!('issues' in got)) return { ...empty, site: site.url, state: got.state, error: got.reason }
283 for (const issue of got.issues) jira[issue.key] = issue
284 }
285 const mine = await searchJira(
286 $,
287 search,
288 {
289 cloudId: site.cloudId,
290 jql: 'assignee = currentUser() AND statusCategory = "In Progress" ORDER BY priority DESC, updated DESC',
291 fields: JIRA_FIELDS,
292 },
293 30,
294 asked,
295 )
296 const inProgress = 'issues' in mine ? mine.issues : []
297 for (const issue of inProgress) jira[issue.key] ??= issue
298 return { jira, inProgress, site: site.url, state: 'ok' }
299}
300
301/**
302 * Reads the Claude sessions and the repository of each session's folder.
303 * @param asked - How the refresh was raised, for the permission of the call.
304 * @returns The sessions found and whether they could be read.
305 */
306const fetchSessions = async (
307 $: EngineInterface,
308 asked: Asked,
309): Promise<{ sessions: SessionInfo[]; state: SourceState; error?: string }> => {
310 const tool = await findTool($, 'ccd_session_mgmt__list_sessions')
311 if (tool === null) return { sessions: [], state: 'unavailable' }
312 const got = await callTool($, tool, { limit: 60 }, asked)
313 if (!('text' in got)) return { sessions: [], state: got.state, error: got.reason }
314 const rows = parseSessions(got.text)
315 const repos = ((await $.store.get('repoOfCwd')) as Record<string, string | null> | undefined) ?? {}
316 let isChanged = false
317 for (const cwd of new Set(rows.map(row => row.cwd))) {
318 if (cwd === '' || cwd in repos) continue
319 try {
320 const ran = await $.process.run(['git', '-C', cwd, 'remote', 'get-url', 'origin'])
321 repos[cwd] = ran.exitCode === 0 ? repoOfRemote(ran.stdout) : null
322 } catch {
323 repos[cwd] = null
324 }
325 isChanged = true
326 }
327 if (isChanged) await $.store.set('repoOfCwd', repos)
328 return { sessions: rows.map(row => ({ ...row, repo: repos[row.cwd] ?? null })), state: 'ok' }
329}
330
331const homeOf = async ($: EngineInterface): Promise<string> =>
332 (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
333
334/** The main local checkout of a repository: scanned once, then reused from `$.store`. */
335const checkoutOf = async ($: EngineInterface, repo: string): Promise<string | null> => {
336 const key = repo.toLowerCase()
337 let found = (await $.store.get('checkouts')) as Record<string, string> | undefined
338 if (found === undefined || !(key in found)) {
339 const home = await homeOf($)
340 const roots = ['', '/Documents', '/Documents/Github', '/Documents/GitHub', '/source/repos', '/repos'].flatMap(sub => [
341 '--root',
342 `${home}${sub}`,
343 ])
344 const ran = await $.process.run(['python', '-I', `${$.plugin.root}/scripts/checkouts.py`, ...roots], { timeoutMs: 60_000 })
345 if (ran.exitCode === 0) {
346 found = JSON.parse(ran.stdout) as Record<string, string>
347 await $.store.set('checkouts', found)
348 }
349 }
350 if (found?.[key] !== undefined) return found[key]
351 // Else a folder a Claude session already worked in for that repository.
352 const repos = ((await $.store.get('repoOfCwd')) as Record<string, string | null> | undefined) ?? {}
353 return Object.entries(repos).find(([, one]) => one?.toLowerCase() === key)?.[0] ?? null
354}
355
356/**
357 * PowerShell's single-quoted, printable-ASCII spelling of a text. Windows
358 * PowerShell passes an argument to a native program without escaping its
359 * double quotes, so a `"` would split it into extra arguments: double quotes
360 * become single ones, and trailing backslashes, which would escape the closing
361 * quote the native program sees, are dropped.
362 */
363const asciiQuoted = (text: string): string =>
364 `'${text
365 .normalize('NFD')
366 .replace(/[’‘“”"]/g, "'")
367 .replace(/[^\x20-\x7e]/g, '')
368 .replace(/\\+$/, '')
369 .replace(/'/g, "''")}'`
370
371/** One piece of work to pick up in a new conversation. */
372type Task = { label: string; name: string; prompt: string; background: string; repo: string | null }
373
374/**
375 * Starts a new conversation for a task: a desktop session when the app offers
376 * `start_session`, else a Claude Code CLI in a new Terminal tab, else the
377 * prompt in this composer. Says which one through a toast; true when a new
378 * conversation started.
379 */
380const startConversation = async ($: EngineInterface, task: Task): Promise<boolean> => {
381 const cwd = task.repo === null ? null : await checkoutOf($, task.repo)
382 const consent = `The user pressed "${task.label}" for ${task.name} in the PR Radar pane to start a new conversation.`
383 const title = `${task.label} ${task.name}`.slice(0, 59)
384
385 const start = await findTool($, '__start_session')
386 if (start !== null) {
387 const ran = await $.tool.call({
388 tool: start as `mcp__${string}__${string}`,
389 consent,
390 initiation: 'user_asked',
391 title,
392 prompt: task.prompt,
393 context: 'fresh',
394 background: task.background,
395 ...(cwd === null ? {} : { cwd }),
396 })
397 if (ran.deny === undefined && ran.isError !== true) {
398 $.ui.toast(`New session: ${title}`, { timeoutMs: 6000 })
399 return true
400 }
401 }
402
403 const terminal = await findTool($, 'terminal__run_in_terminal')
404 if (terminal !== null) {
405 const exe = (await $.env.get('CLAUDE_CODE_EXECPATH')) ?? 'claude'
406 const run = `& ${asciiQuoted(exe)} ${asciiQuoted(task.prompt)}`
407 const command = cwd === null ? run : `Set-Location -LiteralPath ${asciiQuoted(cwd)}; ${run}`
408 const ran = await $.tool.call({ tool: terminal as `mcp__${string}__${string}`, consent, command, title })
409 if (ran.deny === undefined && ran.isError !== true) {
410 $.ui.toast(`New conversation in the Terminal: ${title}`, { timeoutMs: 6000 })
411 return true
412 }
413 }
414
415 await $.prompt.fill({ text: task.prompt })
416 $.ui.toast('No new session possible here: the prompt is in the composer', { timeoutMs: 6000 })
417 return false
418}
419
420const nameOf = (repo: string | null): string => (repo === null ? '?' : (repo.split('/')[1] ?? repo))
421
422/** A PR's next step in a new conversation; one that starts marks the PR in progress. */
423const launch = async ($: EngineInterface, row: PrRow, action: { label: string; text: string }): Promise<void> => {
424 const isStarted = await startConversation($, {
425 label: action.label,
426 name: `${nameOf(row.repo)}#${row.number}`,
427 prompt: action.text,
428 background: `Started from the PR Radar for the PR ${row.url} “${row.title}” (branch ${row.branch} into ${row.base}). State: ${row.reason}.${row.jiraKey === null ? '' : ` Jira issue ${row.jiraKey}.`}`,
429 repo: row.repo,
430 })
431 if (isStarted) await setMark($, row, true)
432}
433
434const CONTINUE_TEXT = 'Resume this task where you left off: finish what remains, then tell me where things stand.'
435
436/**
437 * Continues an unfinished session where it stopped: a message sent to it
438 * (queued if it is busy); a new conversation in its folder when it cannot take one.
439 */
440const continueSession = async ($: EngineInterface, session: SessionInfo, row: PrRow | null): Promise<void> => {
441 const send = await findTool($, 'ccd_session_mgmt__send_message')
442 if (send !== null) {
443 const ran = await $.tool.call({
444 tool: send as `mcp__${string}__${string}`,
445 consent: `The user pressed "Continue" on the session "${session.title}" in the PR Radar pane.`,
446 session_id: session.id,
447 message: CONTINUE_TEXT,
448 })
449 if (ran.deny === undefined && ran.isError !== true) {
450 if (row !== null) await setMark($, row, true)
451 $.ui.toast(`Session resumed: ${session.title}`.slice(0, 80), { timeoutMs: 6000 })
452 return
453 }
454 }
455 const isStarted = await startConversation($, {
456 label: 'Continue',
457 name: session.title,
458 prompt: `Resume the task of the Claude session “${session.title}” (folder ${session.cwd}): find where it stopped, finish what remains, then tell me where things stand.`,
459 background: `Started from the PR Radar to continue the session “${session.title}”, which could not take a message. Its folder: ${session.cwd}.`,
460 repo: session.repo,
461 })
462 if (isStarted && row !== null) await setMark($, row, true)
463}
464
465/** Continues an unfinished Agent Context run: in its own session when it is open, else a new conversation. */
466const continueRun = async ($: EngineInterface, run: RunInfo, sessions: SessionInfo[]): Promise<void> => {
467 const target = run.pr !== null ? `#${run.pr}` : (run.jiraKey ?? run.branch ?? '')
468 const own = sessions.find(s => run.sessionId !== null && (s.id === run.sessionId || s.id === `local_${run.sessionId}`))
469 if (own !== undefined) return continueSession($, own, null)
470 const workflow = run.workflow.split(' -> ').at(-1) ?? run.workflow
471 await startConversation($, {
472 label: 'Continue',
473 name: `${nameOf(run.repo)} ${target}`.trim(),
474 prompt: `Resume the Agent Context run ${run.runId} (${workflow}) on ${run.repo ?? 'its repository'} ${target}: reread its run record, then finish the remaining steps.`,
475 background: `Started from the PR Radar: the run ${run.runId} (${run.workflow}) has been “${run.status}” since ${run.startedAt}${run.client === null ? '' : `, started by ${run.client}`}.`,
476 repo: run.repo,
477 })
478}
479
480/** Continues a Jira issue in progress that has no PR yet, in a new conversation. */
481const continueIssue = async ($: EngineInterface, issue: JiraInfo): Promise<void> => {
482 await startConversation($, {
483 label: 'Continue',
484 name: issue.key,
485 prompt: `Continue the Jira issue ${issue.key} “${issue.summary}”: check where the work stands (branch, PR, sessions), then propose and do the next steps.`,
486 background: `Started from the PR Radar: ${issue.key} is “${issue.status}” in Jira, assigned to me, and has no open PR yet.`,
487 repo: null,
488 })
489}
490
491/**
492 * Marks my PR ready for review, or turns it back into a draft, then refreshes
493 * the radar. Says the result, or why `gh` failed, in a toast.
494 * @param options - The mod's options, for the refresh.
495 * @param row - The PR to change.
496 * @param isReady - True runs `gh pr ready`, false runs `gh pr ready --undo`.
497 */
498const setReady = async ($: EngineInterface, options: Options, row: PrRow, isReady: boolean): Promise<void> => {
499 const name = `${nameOf(row.repo)}#${row.number}`
500 const argv = ['gh', 'pr', 'ready', row.url, ...(isReady ? [] : ['--undo'])]
501 const ran = await $.process.run(argv, { timeoutMs: 60_000 }).catch((error: unknown) => ({
502 exitCode: -1,
503 stderr: error instanceof Error ? error.message : String(error),
504 }))
505 if (ran.exitCode !== 0) {
506 $.ui.toast(`${name}: gh pr ready failed: ${ran.stderr.trim().slice(0, 160)}`, { timeoutMs: 8000 })
507 return
508 }
509 $.ui.toast(isReady ? `${name} is ready for review` : `${name} is back to draft`, { timeoutMs: 4000 })
510 await refresh($, options, true)
511}
512
513let isRunning = false
514
515/** What a refresh found: its snapshot, and each source whose permission was refused, with the reason. */
516type Refreshed = { snapshot: Snapshot; refused: string[] }
517
518/**
519 * Gathers every source into one snapshot, shares it through `$.store` and
520 * redraws.
521 * @param options - The mod's options.
522 * @param isAttended - True when the person asked for it.
523 * @param consent - The person's own words for the press, passed with the tool calls.
524 * @returns What the refresh found (its snapshot and the sources still refused),
525 * or null when a refresh of this session is already running.
526 */
527const refresh = async ($: EngineInterface, options: Options, isAttended: boolean, consent?: string): Promise<Refreshed | null> => {
528 if (isRunning) return null
529 isRunning = true
530 const asked: Asked = consent === undefined ? { isAttended } : { isAttended, consent }
531 await update($, refreshingAtom, () => true)
532 try {
533 const before = await read($, snapshotAtom)
534 const errors: string[] = []
535 const startedAt = await $.clock.now()
536 // A spent budget pauses every GitHub refresh until its reset; a low one
537 // pauses only the automatic ones. The radar then keeps its last PRs.
538 const pause = (await $.store.get(PAUSE_KEY)) as Pause | undefined
539 const isPaused = pause !== undefined && startedAt < pause.until && (pause.isSpent || !isAttended)
540 // GitHub and the run records are read side by side; a slow source that
541 // times out keeps what the last refresh found rather than showing an error.
542 const [github, records] = await Promise.allSettled([isPaused ? Promise.resolve(null) : fetchPrs($), fetchRuns($, options)])
543 let login = before?.login ?? ''
544 let prs: PrRow[] = before?.prs ?? []
545 if (isPaused) errors.push(pauseNote(startedAt, pause.until, pause.isSpent))
546 else if (github.status === 'fulfilled' && github.value !== null) {
547 ;({ login, prs } = github.value)
548 const note = await notePause($, github.value.budget, startedAt)
549 if (note !== undefined) errors.push(note)
550 } else if (github.status === 'rejected' && isRateLimited(errorText(github.reason))) {
551 const until = await resetOf($, startedAt)
552 await $.store.set(PAUSE_KEY, { until, isSpent: true } satisfies Pause)
553 errors.push(pauseNote(startedAt, until, true))
554 } else if (github.status === 'rejected' && !(isTimeout(github.reason) && before !== null)) {
555 errors.push(`GitHub: ${errorText(github.reason)}`)
556 }
557 let runs: RunInfo[] = before?.runs ?? []
558 if (records.status === 'fulfilled') runs = records.value
559 else if (!(isTimeout(records.reason) && before !== null)) errors.push(`Agent Context: ${errorText(records.reason)}`)
560 const keys = [...new Set(prs.map(row => row.jiraKey).filter((key): key is string => key !== null))]
561 let jira: JiraResult
562 try {
563 jira = await fetchJira($, keys, asked)
564 } catch {
565 // A throw here comes from reading the connector's answer, whose
566 // exception text means nothing to the person: name the cause instead.
567 jira = { jira: before?.jira ?? {}, inProgress: before?.jiraInProgress ?? [], site: before?.jiraSite ?? null, state: 'error', error: JIRA_UNREADABLE }
568 }
569 if (jira.state !== 'ok' && before !== null) {
570 jira = { ...jira, jira: before.jira, inProgress: before.jiraInProgress, site: jira.site ?? before.jiraSite }
571 }
572 if (jira.error !== undefined && jira.state === 'error') errors.push(`Jira: ${jira.error}`)
573 let sessions: Awaited<ReturnType<typeof fetchSessions>>
574 try {
575 sessions = await fetchSessions($, asked)
576 } catch (error) {
577 sessions = { sessions: before?.sessions ?? [], state: 'error', error: errorText(error) }
578 }
579 if (sessions.state !== 'ok' && before !== null) sessions = { ...sessions, sessions: before.sessions }
580 if (sessions.error !== undefined && sessions.state === 'error') errors.push(`Sessions: ${sessions.error}`)
581
582 const snapshot: Snapshot = {
583 fetchedAt: await $.clock.now(),
584 login,
585 prs: await withMarks($, prs.map(row => applyJira(row, jira.jira)), jira.jira),
586 jira: jira.jira,
587 jiraInProgress: jira.inProgress,
588 jiraSite: jira.site,
589 jiraState: jira.state,
590 sessions: sessions.sessions,
591 sessionsState: sessions.state,
592 runs,
593 errors,
594 }
595 await update($, snapshotAtom, () => snapshot)
596 await $.store.set(STORE_KEY, snapshot)
597 $.ui.status(statusText(snapshot))
598 const urgent = newlyUrgent(before, snapshot)
599 if (urgent.length === 1 && urgent[0] !== undefined) {
600 $.ui.toast(`${urgent[0].repo.split('/')[1]}#${urgent[0].number}: ${urgent[0].reason}`, { timeoutMs: 8000 })
601 } else if (urgent.length > 1) {
602 $.ui.toast(`${urgent.length} PRs need your attention`, { timeoutMs: 8000 })
603 }
604 const refused = [
605 ...(jira.state === 'needs-permission' ? [`Jira: ${jira.error ?? 'refused'}`] : []),
606 ...(sessions.state === 'needs-permission' ? [`Sessions: ${sessions.error ?? 'refused'}`] : []),
607 ]
608 return { snapshot, refused }
609 } finally {
610 isRunning = false
611 await update($, refreshingAtom, () => false)
612 }
613}
614
615/**
616 * The Authorize button: refreshes with the person's consent, so that reading
617 * Jira and the sessions runs as their request, then says in a toast what came
618 * of it.
619 * @param options - The mod's options, for the refresh.
620 */
621const authorize = async ($: EngineInterface, options: Options): Promise<void> => {
622 const consent = 'The user pressed "Authorize" in the PR Radar pane so that it reads their Jira issues and their Claude sessions.'
623 const done = await refresh($, options, true, consent)
624 if (done === null) {
625 $.ui.toast('A refresh is running; press Authorize again once it ends', { timeoutMs: 6000 })
626 } else if (done.refused.length > 0) {
627 $.ui.toast(`Still refused: ${done.refused.join('; ')}`.slice(0, 200), { timeoutMs: 10_000 })
628 } else {
629 const { jiraState, sessionsState } = done.snapshot
630 const unread = [
631 ...(jiraState === 'ok' ? [] : [`Jira (${jiraState})`]),
632 ...(sessionsState === 'ok' ? [] : [`the sessions (${sessionsState})`]),
633 ]
634 $.ui.toast(
635 unread.length === 0 ? 'Authorized: the radar reads Jira and the sessions' : `Authorized, but not read: ${unread.join(', ')}`,
636 { timeoutMs: unread.length === 0 ? 4000 : 8000 },
637 )
638 }
639}
640
641const MARKS_KEY = 'marks'
642
643/** Applies the marks every session shares, keeping only those still current, and orders the lanes. */
644const withMarks = async ($: EngineInterface, rows: PrRow[], jira: Record<string, JiraInfo>): Promise<PrRow[]> => {
645 const marks = ((await $.store.get(MARKS_KEY)) as Marks | undefined) ?? {}
646 const applied = applyMarks(rows, marks)
647 if (Object.keys(applied.marks).length !== Object.keys(marks).length) await $.store.set(MARKS_KEY, applied.marks)
648 return sortPrs(applied.rows, jira)
649}
650
651const show = async ($: EngineInterface, snapshot: Snapshot): Promise<void> => {
652 await update($, snapshotAtom, () => snapshot)
653 $.ui.status(statusText(snapshot))
654}
655
656/** Marks a PR in progress, or takes the mark back; its next change on GitHub drops the mark. */
657const setMark = async ($: EngineInterface, row: PrRow, isOn: boolean): Promise<void> => {
658 const snapshot = await read($, snapshotAtom)
659 if (snapshot === null) return
660 const marks = { ...(((await $.store.get(MARKS_KEY)) as Marks | undefined) ?? {}) }
661 if (isOn) marks[row.id] = row.updatedAt
662 else delete marks[row.id]
663 await $.store.set(MARKS_KEY, marks)
664 const next = { ...snapshot, prs: await withMarks($, snapshot.prs, snapshot.jira) }
665 await show($, next)
666 await $.store.set(STORE_KEY, next)
667}
668
669/** Adopts a fresher snapshot or marks another session stored, or refreshes once it is stale. */
670const tick = async ($: EngineInterface, options: Options): Promise<void> => {
671 const intervalMs = Math.max(1, options.refreshMinutes ?? 5) * 60_000
672 const now = await $.clock.now()
673 await update($, clockAtom, () => now)
674 const stored = (await $.store.get(STORE_KEY)) as Snapshot | undefined
675 const shown = await read($, snapshotAtom)
676 const base = stored !== undefined && (shown === null || stored.fetchedAt > shown.fetchedAt) ? stored : shown
677 if (base !== null) {
678 const prs = await withMarks($, base.prs, base.jira)
679 const lanes = (rows: PrRow[]): string => rows.map(row => `${row.id}:${row.lane}`).join()
680 if (base !== shown || lanes(prs) !== lanes(shown.prs)) await show($, { ...base, prs })
681 }
682 const latest = Math.max(stored?.fetchedAt ?? 0, shown?.fetchedAt ?? 0)
683 if (now - latest < intervalMs) return
684 // One session refreshes for all: a lease taken since the last snapshot means
685 // another session is already refreshing.
686 const lease = (await $.store.get(LEASE_KEY)) as number | undefined
687 if (lease !== undefined && lease > latest && now - lease < LEASE_MS) return
688 await $.store.set(LEASE_KEY, now)
689 await refresh($, options, false)
690}
691
692const openPane = async ($: EngineInterface, isFocused: boolean): Promise<void> => {
693 await $.ui.open({ id: PANE, title: TITLE, ...(isFocused ? { focus: true as const } : {}) })
694 await update($, paneOpenAtom, () => true)
695}
696
697const quietly = (work: () => Promise<unknown>): void => {
698 void work().catch(() => undefined)
699}
700
701/**
702 * Registers the radar: the `/prs` command, the `snapshot` tool, the periodic
703 * refresh, the pane, and the button above the prompt that reopens the pane
704 * while it is closed. Each PR card holds three rows split left and right: the
705 * title and repository; why it is in its lane and its CI, threads and
706 * priority; its number, Jira issue, linked work, age and next action.
707 * @param on - Adds a hook.
708 * @param rawOptions - The `userConfig` values of `plugin.json`.
709 */
710export const register: Register = (on, rawOptions) => {
711 const options = rawOptions as Options
712
713 on('session.start', async ($, e, next) => {
714 await $.command.register({
715 name: 'prs',
716 description: 'Opens the PR Radar (GitHub PRs, Jira, Claude sessions); “/prs refresh” refreshes it',
717 argumentHint: '[refresh]',
718 })
719 await $.tool.register({
720 name: 'snapshot',
721 description:
722 'PR Radar: the account’s open GitHub PRs (authored, review requested, reviewed, assigned), sorted by who must act, with CI, review threads, Jira status and priority, linked Claude sessions and unfinished Agent Context work. Use it to answer “what should I do next” or “where are my PRs”.',
723 inputSchema: { type: 'object', properties: { refresh: { type: 'boolean', description: 'Refresh before answering' } } },
724 })
725 $.clock.every(TICK_MS, () => quietly(() => tick($, options)))
726 quietly(() => tick($, options))
727 quietly(() => openPane($, false))
728
729 return next(e)
730 })
731
732 on('command.run', { command: 'prs' }, async ($, e) => {
733 const isRefresh = /refresh|actualise/i.test(e.args)
734 await openPane($, true)
735 if (isRefresh) {
736 await refresh($, options, true)
737 const snapshot = await read($, snapshotAtom)
738 return { text: snapshot === null ? 'PR Radar refreshed.' : `PR Radar refreshed: ${statusText(snapshot) ?? 'no open PR'}.` }
739 }
740 quietly(() => tick($, options))
741 return { text: 'PR Radar opened.' }
742 })
743
744 on('tool.call', { tool: 'mcp__pr-radar__snapshot' }, async ($, e) => {
745 const wantsRefresh = (e as { refresh?: unknown }).refresh === true
746 if (wantsRefresh || (await read($, snapshotAtom)) === null) await refresh($, options, true)
747 const snapshot = await read($, snapshotAtom)
748 const text = snapshot === null ? 'PR Radar: no data yet.' : summary(snapshot, await $.clock.now())
749 return { result: text }
750 }).catch(() => ({ result: 'PR Radar: cannot read the data right now.' }))
751
752 on('ui.close', async ($, e, next) => {
753 if (e.id === PANE) await update($, paneOpenAtom, () => false)
754 return next(e)
755 })
756
757 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
758 const snapshot = await read($, snapshotAtom)
759 if (e.props.hasSurvey || (await read($, paneOpenAtom))) return next(e)
760 const { Box, Button, Text } = $.ui.resolve(e)
761 const counts = snapshot === null ? null : laneCounts(snapshot.prs)
762 return (
763 <Box flexDirection="row">
764 <Button key="open-radar" plain onPress={() => quietly(() => openPane($, true))}>
765 <Text color="claude">◎ </Text>
766 <Text>{TITLE}</Text>
767 {LANES.filter(lane => lane.id !== 'draft' && lane.id !== 'onhold' && (counts?.[lane.id] ?? 0) > 0).map(lane => (
768 <Text color={lane.color}>
769 {' '}
770 {lane.glyph} {counts?.[lane.id]}
771 </Text>
772 ))}
773 </Button>
774 </Box>
775 )
776 })
777
778 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
779 const { Box, Button, Link, Text } = $.ui.resolve(e)
780 const snapshot = await read($, snapshotAtom)
781 const isRefreshing = await read($, refreshingAtom)
782 const filter = await read($, filterAtom)
783 const folded = await read($, foldedAtom)
784 const now = Math.max(await read($, clockAtom), snapshot?.fetchedAt ?? 0)
785
786 const refreshButton = (
787 <Button
788 key="refresh"
789 label={isRefreshing ? '…' : '↻'}
790 plain
791 dimColor
792 onPress={() => quietly(() => refresh($, options, true))}
793 />
794 )
795
796 if (snapshot === null) {
797 return (
798 <Box flexDirection="row" gap={1}>
799 <Text dimColor>{isRefreshing ? 'Reading GitHub, Jira and the sessions…' : 'No data yet.'}</Text>
800 {refreshButton}
801 </Box>
802 )
803 }
804
805 const counts = laneCounts(snapshot.prs)
806 const rows = snapshot.prs.filter(row => matchesFilter(row, filter))
807 const jiraUrl = (key: string): string | null => (snapshot.jiraSite === null ? null : `${snapshot.jiraSite}/browse/${key}`)
808 const repoName = (repo: string): string => repo.split('/')[1] ?? repo
809
810 const header = (
811 <Box flexDirection="row" justifyContent="space-between" flexWrap="wrap" columnGap={2}>
812 <Text>
813 {LANES.filter(lane => counts[lane.id] > 0).map(lane => (
814 <Text color={lane.color} dimColor={lane.id === 'onhold' || lane.id === 'draft'}>
815 {lane.glyph} {counts[lane.id]}
816 {' '}
817 </Text>
818 ))}
819 </Text>
820 <Box flexDirection="row" gap={1}>
821 {FILTERS.map(one => (
822 <Button
823 key={`filter-${one.id}`}
824 label={one.label}
825 plain={one.id === filter ? undefined : true}
826 variant={one.id === filter ? 'primary' : undefined}
827 dimColor={one.id !== filter}
828 onPress={() => quietly(() => update($, filterAtom, () => one.id))}
829 />
830 ))}
831 <Text dimColor> {isRefreshing ? '' : ago(now, snapshot.fetchedAt)}</Text>
832 {refreshButton}
833 </Box>
834 </Box>
835 )
836
837 const notes: RenderNode[] = []
838 for (const error of snapshot.errors) notes.push(<Text color="error" wrap="truncate-end">⚠ {error}</Text>)
839 if (snapshot.jiraState === 'needs-permission' || snapshot.sessionsState === 'needs-permission') {
840 notes.push(
841 <Box flexDirection="row" gap={1}>
842 <Text color="warning">{snapshot.jiraState === 'needs-permission' ? 'Jira' : 'Sessions'}: permission needed</Text>
843 <Button key="authorize" label="Authorize" plain onPress={() => quietly(() => authorize($, options))} />
844 </Box>,
845 )
846 }
847
848 const card = (row: PrRow, color: string, isMuted: boolean): RenderNode => {
849 const issue = row.jiraKey === null ? undefined : snapshot.jira[row.jiraKey]
850 const sessions = sessionsOf(row, snapshot.sessions)
851 const runs = runsOf(row, snapshot.runs)
852 const action = isMuted ? null : actionPrompt(row)
853 const canContinue = !isMuted && (row.heldLane ?? row.lane) !== 'ready'
854 const toggle = isMuted ? null : draftToggle(row)
855 const issueUrl = row.jiraKey === null ? null : jiraUrl(row.jiraKey)
856 const mark = issue?.priority === null || issue?.priority === undefined ? null : priorityMark(issue.priority)
857 const isRunning = sessions.some(s => s.isRunning)
858 const signals: RenderNode[] = []
859 if (row.ci === 'failure') signals.push(<Text color={isMuted ? 'inactive' : 'error'}>✖ CI</Text>)
860 if (row.ci === 'pending') signals.push(<Text color="warning">◌ CI</Text>)
861 if (row.ci === 'success' && !isMuted) signals.push(<Text color="success">✔</Text>)
862 if (row.hasConflicts) signals.push(<Text color={isMuted ? 'inactive' : 'error'}>⚡</Text>)
863 if (row.openThreads > 0) signals.push(<Text color={isMuted ? 'inactive' : 'warning'}>💬{row.openThreads}</Text>)
864 if (mark !== null) signals.push(<Text color={isMuted ? 'inactive' : mark.color}>{mark.glyph}</Text>)
865 return (
866 <Box key={row.id} flexDirection="column" borderStyle="round" borderColor={color} borderDimColor={isMuted} paddingX={1}>
867 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
868 <Box flexShrink={1}>
869 <Text bold={!isMuted} dimColor={isMuted} wrap="truncate-end">{row.title}</Text>
870 </Box>
871 <Text dimColor>{repoName(row.repo)}</Text>
872 </Box>
873 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
874 <Box flexShrink={1}>
875 <Text color={color} dimColor={isMuted} wrap="truncate-end">{row.reason}</Text>
876 </Box>
877 <Box flexDirection="row" columnGap={1}>{signals}</Box>
878 </Box>
879 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
880 <Box flexDirection="row" columnGap={2} flexShrink={1}>
881 <Box flexDirection="row">
882 <Link href={row.url} label={`#${row.number}`} />
883 <Button
884 key={`copy-url-${row.id}`}
885 label="⧉"
886 plain
887 dimColor
888 onPress={press =>
889 quietly(async () => {
890 await $.ui.copy({ text: row.url, surface: press.surface })
891 $.ui.toast(`Link copied: ${row.url}`, { timeoutMs: 3000 })
892 })
893 }
894 />
895 </Box>
896 {row.jiraKey !== null && issueUrl !== null ? <Link href={issueUrl} label={row.jiraKey} /> : null}
897 {issue !== undefined && !isMuted ? <Text dimColor wrap="truncate-end">{issue.status}</Text> : null}
898 </Box>
899 <Box flexDirection="row" columnGap={1}>
900 {sessions.length > 0 && sessions[0] !== undefined && canContinue ? (
901 <Button
902 key={`cont-${row.id}`}
903 plain
904 onPress={() => quietly(() => continueSession($, sessions[0]!, row))}
905 >
906 <Text color={isRunning ? 'claude' : 'inactive'}>{isRunning ? '◉' : '○'}</Text>
907 <Text dimColor> Continue</Text>
908 </Button>
909 ) : sessions.length > 0 ? (
910 <Text color="inactive">{isRunning ? '◉' : '○'}</Text>
911 ) : null}
912 {runs.length > 0 ? <Text color="inactive">⚙</Text> : null}
913 <Text dimColor>{ago(now, row.updatedAt)}</Text>
914 {isMuted ? null : (
915 <Button
916 key={`wip-${row.id}`}
917 label={row.lane === 'inprogress' ? 'Remove' : 'In progress'}
918 plain
919 dimColor
920 onPress={() => quietly(() => setMark($, row, row.lane !== 'inprogress'))}
921 />
922 )}
923 {toggle !== null ? (
924 <Button
925 key={`ready-${row.id}`}
926 label={toggle.label}
927 plain
928 dimColor={!toggle.isReady}
929 onPress={() => quietly(() => setReady($, options, row, toggle.isReady))}
930 />
931 ) : null}
932 {action !== null ? (
933 <Button
934 key={`act-${row.id}`}
935 label={action.label}
936 plain
937 onPress={() => quietly(() => launch($, row, action))}
938 />
939 ) : null}
940 </Box>
941 </Box>
942 </Box>
943 )
944 }
945
946 const foldButton = (key: string, label: RenderNode[], id: string): RenderNode => (
947 <Button
948 key={key}
949 plain
950 onPress={() =>
951 quietly(() =>
952 update($, foldedAtom, list =>
953 list.includes(id as Lane) ? list.filter(one => one !== id) : [...list, id as Lane],
954 ),
955 )
956 }
957 >
958 <Text dimColor>{folded.includes(id as Lane) ? '▸ ' : '▾ '}</Text>
959 {label}
960 </Button>
961 )
962
963 const lanes: RenderNode[] = []
964 for (const lane of LANES) {
965 const inLane = rows.filter(row => row.lane === lane.id)
966 if (inLane.length === 0) continue
967 const isMuted = lane.id === 'onhold'
968 lanes.push(
969 <Box key={`lane-${lane.id}`} flexDirection="column">
970 {foldButton(
971 `toggle-${lane.id}`,
972 [
973 <Text bold={!isMuted} color={lane.color} dimColor={isMuted}>
974 {lane.glyph} {lane.label}
975 </Text>,
976 <Text dimColor> {inLane.length}</Text>,
977 ],
978 lane.id,
979 )}
980 {folded.includes(lane.id) ? null : (
981 <Box flexDirection="column">{inLane.map(row => card(row, lane.color, isMuted))}</Box>
982 )}
983 </Box>,
984 )
985 }
986 if (lanes.length === 0) lanes.push(<Text dimColor>No open PR for this filter.</Text>)
987
988 const shownIds = new Set(snapshot.prs.map(row => row.id))
989 const linkedSessions = new Set(snapshot.prs.flatMap(row => sessionsOf(row, snapshot.sessions).map(s => s.id)))
990 const linkedRuns = new Set(snapshot.prs.flatMap(row => runsOf(row, snapshot.runs).map(run => run.runId)))
991 const weekAgo = now - 7 * 24 * 3600_000
992 const lonelyIssues = workingIssues(snapshot).slice(0, 6)
993 const lonelySessions = snapshot.sessions
994 .filter(s => !linkedSessions.has(s.id) && (s.isRunning || Date.parse(s.lastActivityAt) >= weekAgo))
995 .slice(0, 6)
996 const lonelyRuns = snapshot.runs
997 .filter(
998 run =>
999 !linkedRuns.has(run.runId) && !(run.repo !== null && run.pr !== null && shownIds.has(`${run.repo}#${run.pr}`)),
1000 )
1001 .slice(0, 6)
1002
1003 const continueButton = (key: string, work: () => Promise<unknown>): RenderNode => (
1004 <Button key={key} label="Continue" plain onPress={() => quietly(work)} />
1005 )
1006
1007 const line = (key: string, left: RenderNode[], right: string, extra: RenderNode | null): RenderNode => (
1008 <Box key={key} flexDirection="row" justifyContent="space-between" columnGap={2}>
1009 <Box flexShrink={1}>
1010 <Text wrap="truncate-end">{left}</Text>
1011 </Box>
1012 <Box flexDirection="row" columnGap={1}>
1013 {right === '' ? null : <Text dimColor>{right}</Text>}
1014 {extra}
1015 </Box>
1016 </Box>
1017 )
1018
1019 const section = (id: string, title: string, items: RenderNode[]): RenderNode | null => {
1020 if (items.length === 0) return null
1021 return (
1022 <Box key={`sec-${id}`} flexDirection="column">
1023 {foldButton(`toggle-${id}`, [<Text bold>{title}</Text>, <Text dimColor> {items.length}</Text>], id)}
1024 {folded.includes(id as Lane) ? null : (
1025 <Box flexDirection="column" paddingLeft={2}>
1026 {items}
1027 </Box>
1028 )}
1029 </Box>
1030 )
1031 }
1032
1033 const extras =
1034 filter === 'mine' || filter === 'reviews'
1035 ? []
1036 : [
1037 section(
1038 'jira',
1039 'Jira in progress without a PR',
1040 lonelyIssues.map(issue => {
1041 const url = jiraUrl(issue.key)
1042 const mark = issue.priority === null ? null : priorityMark(issue.priority)
1043 return line(
1044 `j-${issue.key}`,
1045 [mark === null ? '' : <Text color={mark.color}>{mark.glyph} </Text>, <Text>{issue.summary}</Text>],
1046 '',
1047 <Box flexDirection="row" columnGap={1}>
1048 {url === null ? <Text dimColor>{issue.key}</Text> : <Link href={url} label={issue.key} />}
1049 {continueButton(`cont-j-${issue.key}`, () => continueIssue($, issue))}
1050 </Box>,
1051 )
1052 }),
1053 ),
1054 section(
1055 'sessions',
1056 'Claude sessions without a PR',
1057 lonelySessions.map(s =>
1058 line(
1059 `s-${s.id}`,
1060 [s.isRunning ? <Text color="claude">◉ </Text> : <Text dimColor>○ </Text>, <Text>{s.title}</Text>],
1061 s.isRunning ? 'running' : ago(now, s.lastActivityAt),
1062 <Box flexDirection="row" columnGap={1}>
1063 {continueButton(`cont-s-${s.id}`, () => continueSession($, s, null))}
1064 {s.link === null ? null : (
1065 <Button
1066 key={`copy-${s.id}`}
1067 label="⧉"
1068 plain
1069 dimColor
1070 onPress={press => quietly(() => $.ui.copy({ text: s.link ?? '', surface: press.surface }))}
1071 />
1072 )}
1073 </Box>,
1074 ),
1075 ),
1076 ),
1077 section(
1078 'runs',
1079 'Unfinished Agent Context work',
1080 lonelyRuns.map(run =>
1081 line(
1082 `r-${run.runId}`,
1083 [
1084 <Text color={run.status === 'blocked' ? 'error' : 'inactive'}>⚙ </Text>,
1085 <Text>
1086 {run.repo === null ? '?' : repoName(run.repo)}{' '}
1087 {run.pr !== null ? `#${run.pr}` : (run.jiraKey ?? run.branch ?? '')}
1088 </Text>,
1089 <Text dimColor> {run.workflow.split(' -> ').at(-1)}</Text>,
1090 ],
1091 `${run.status} · ${ago(now, run.startedAt)}`,
1092 continueButton(`cont-r-${run.runId}`, () => continueRun($, run, snapshot.sessions)),
1093 ),
1094 ),
1095 ),
1096 ]
1097
1098 return (
1099 <Box flexDirection="column" gap={1}>
1100 {header}
1101 {notes.length > 0 ? <Box flexDirection="column">{notes}</Box> : null}
1102 {lanes}
1103 {extras}
1104 </Box>
1105 )
1106 })
1107}
1108hooks/model.ts 601 lines1import type { Ci, JiraInfo, Lane, Marks, PrRow, Role, RunInfo, SessionInfo, Snapshot } from '../types'
2
3/**
4 * The first GraphQL query: the ids of the account's authored, requested,
5 * reviewed and assigned open PRs. GitHub prices a query from the `first` and
6 * `last` it asks for, not from what it finds, so the costly fields wait for
7 * {@link DETAIL_QUERY}, which asks for the PRs found and no more.
8 */
9export const ID_QUERY = `query($q1:String!,$q2:String!,$q3:String!,$q4:String!){
10 viewer{login}
11 authored: search(query:$q1,type:ISSUE,first:50){nodes{... on PullRequest{id}}}
12 requested: search(query:$q2,type:ISSUE,first:50){nodes{... on PullRequest{id}}}
13 reviewed: search(query:$q3,type:ISSUE,first:50){nodes{... on PullRequest{id}}}
14 assigned: search(query:$q4,type:ISSUE,first:50){nodes{... on PullRequest{id}}}
15}`
16
17/** The second GraphQL query: the details of the PRs found, and the budget left. */
18export const DETAIL_QUERY = `query($ids:[ID!]!){
19 rateLimit{remaining resetAt}
20 nodes(ids:$ids){...P}
21}
22fragment P on PullRequest{
23 id number title url isDraft createdAt updatedAt headRefName baseRefName
24 additions deletions
25 repository{nameWithOwner}
26 author{login}
27 reviewDecision mergeable
28 labels(first:10){nodes{name}}
29 reviewRequests(first:10){nodes{requestedReviewer{... on User{login} ... on Team{slug}}}}
30 latestOpinionatedReviews(first:20){nodes{author{login} state submittedAt}}
31 latestReviews(first:20){nodes{author{login} state submittedAt}}
32 reviewThreads(first:100){nodes{isResolved isOutdated comments(last:1){nodes{author{login}}}}}
33 commits(last:1){nodes{commit{committedDate statusCheckRollup{state}}}}
34}`
35
36/** The four search strings, for one login. */
37export const searches = (login: string): [string, string, string, string] => {
38 const open = 'is:pr is:open archived:false'
39 return [
40 `${open} author:${login}`,
41 `${open} review-requested:${login}`,
42 `${open} reviewed-by:${login} -author:${login}`,
43 `${open} assignee:${login} -author:${login}`,
44 ]
45}
46
47type Login = { login?: string } | null
48type Review = { author: Login; state: string; submittedAt: string | null }
49/** One pull request as the GitHub GraphQL search returns it. */
50export type RawPr = {
51 id: string
52 number: number
53 title: string
54 url: string
55 isDraft: boolean
56 createdAt: string
57 updatedAt: string
58 headRefName: string
59 baseRefName: string
60 additions: number
61 deletions: number
62 repository: { nameWithOwner: string }
63 author: Login
64 reviewDecision: string | null
65 mergeable: string | null
66 labels: { nodes: { name: string }[] }
67 reviewRequests: { nodes: { requestedReviewer: { login?: string; slug?: string } | null }[] }
68 latestOpinionatedReviews: { nodes: Review[] }
69 latestReviews: { nodes: Review[] }
70 reviewThreads: {
71 nodes: { isResolved: boolean; isOutdated: boolean; comments: { nodes: { author: Login }[] } }[]
72 }
73 commits: {
74 nodes: { commit: { committedDate: string; statusCheckRollup: { state: string } | null } }[]
75 }
76}
77/** The GraphQL answer: the viewer and one search per role. */
78export type RawSearch = {
79 viewer: { login: string }
80 authored: { nodes: (RawPr | Record<string, never>)[] }
81 requested: { nodes: (RawPr | Record<string, never>)[] }
82 reviewed: { nodes: (RawPr | Record<string, never>)[] }
83 assigned: { nodes: (RawPr | Record<string, never>)[] }
84}
85
86type Ids = { nodes: { id?: string }[] }
87/** The {@link ID_QUERY} answer: the viewer and the PR ids of each role. */
88export type RawIds = { viewer: { login: string }; authored: Ids; requested: Ids; reviewed: Ids; assigned: Ids }
89/** The {@link DETAIL_QUERY} answer. */
90export type RawDetails = { rateLimit: { remaining: number; resetAt: string } | null; nodes: (RawPr | null)[] }
91
92/** The most PRs {@link DETAIL_QUERY} asks for, the `nodes(ids:)` limit. */
93export const MAX_PRS = 100
94
95/**
96 * The distinct PR ids of the four searches, authored first, then requested,
97 * reviewed and assigned, at most {@link MAX_PRS}.
98 * @param raw - The {@link ID_QUERY} answer.
99 * @returns The ids to ask {@link DETAIL_QUERY} for.
100 */
101export const idsOf = (raw: RawIds): string[] => {
102 const ids = [raw.authored, raw.requested, raw.reviewed, raw.assigned].flatMap(part =>
103 part.nodes.map(node => node.id).filter((id): id is string => id !== undefined),
104 )
105 return [...new Set(ids)].slice(0, MAX_PRS)
106}
107
108/**
109 * The searches again, each id replaced by its details; an id without details
110 * (a PR closed between the two queries) is left out.
111 * @param raw - The {@link ID_QUERY} answer.
112 * @param details - The PRs of the {@link DETAIL_QUERY} answer.
113 * @returns What {@link collectPrs} reads.
114 */
115export const withDetails = (raw: RawIds, details: (RawPr | null)[]): RawSearch => {
116 const byId = new Map(details.filter((pr): pr is RawPr => pr !== null).map(pr => [pr.id, pr]))
117 const fill = (part: Ids): { nodes: RawPr[] } => ({
118 nodes: part.nodes.flatMap(node => {
119 const pr = node.id === undefined ? undefined : byId.get(node.id)
120 return pr === undefined ? [] : [pr]
121 }),
122 })
123 return {
124 viewer: raw.viewer,
125 authored: fill(raw.authored),
126 requested: fill(raw.requested),
127 reviewed: fill(raw.reviewed),
128 assigned: fill(raw.assigned),
129 }
130}
131
132/** Below this many GraphQL points left, an automatic refresh waits for the reset. */
133export const LOW_BUDGET = 300
134
135/** Whether a `gh` error says the GitHub API rate limit is spent. */
136export const isRateLimited = (text: string): boolean => /rate limit/i.test(text)
137
138/**
139 * The note shown while GitHub refreshes are paused.
140 * @param now - The time now, in milliseconds.
141 * @param until - When the pause ends, in milliseconds.
142 * @param isSpent - True when the budget is spent, false when it is only low.
143 * @returns The note, saying when the radar asks GitHub again.
144 */
145export const pauseNote = (now: number, until: number, isSpent: boolean): string => {
146 const minutes = Math.max(1, Math.ceil((until - now) / 60000))
147 return isSpent
148 ? `GitHub: API rate limit reached; the radar asks again in ${minutes} min`
149 : `GitHub: API budget low; automatic refreshes wait ${minutes} min`
150}
151
152/** The lanes, in display order, with their label, glyph and theme color. */
153export const LANES: { id: Lane; label: string; glyph: string; color: string }[] = [
154 { id: 'blocked', label: 'Blocked', glyph: '✖', color: 'error' },
155 { id: 'action', label: 'Your turn', glyph: '●', color: 'warning' },
156 { id: 'inprogress', label: 'In progress', glyph: '◐', color: 'claude' },
157 { id: 'ready', label: 'Ready for manual review', glyph: '✔', color: 'success' },
158 { id: 'waiting', label: 'Waiting on others', glyph: '◷', color: 'suggestion' },
159 { id: 'draft', label: 'Drafts', glyph: '✎', color: 'inactive' },
160 { id: 'onhold', label: 'On hold', glyph: '⏸', color: 'inactive' },
161]
162
163const JIRA_KEY = /\b([A-Z][A-Z0-9]{1,9}-\d+)\b/
164
165/** The first Jira key in the given texts, in order. */
166export const jiraKeyOf = (...texts: (string | null | undefined)[]): string | null => {
167 for (const text of texts) {
168 const match = text === null || text === undefined ? null : JIRA_KEY.exec(text)
169 if (match?.[1] !== undefined) return match[1]
170 }
171 return null
172}
173
174const BLOCKED_TEXT = /block|bloqu|impediment/i
175const ON_HOLD_TEXT = /on.?hold|en pause|paused|postponed|deferred|report[ée]/i
176
177/** Whether a Jira status or a label says the work is blocked. */
178export const isBlockedText = (text: string | null | undefined): boolean =>
179 text !== null && text !== undefined && BLOCKED_TEXT.test(text)
180
181/** Whether a Jira status or a label says the work is parked for later. */
182export const isOnHoldText = (text: string | null | undefined): boolean =>
183 text !== null && text !== undefined && ON_HOLD_TEXT.test(text)
184
185/**
186 * Whether a Jira status means someone is working on the issue right now:
187 * `In Progress` itself, not the later steps of the same category such as
188 * `Code Review` or `Final Approval`.
189 */
190export const isWorkingStatus = (status: string | null | undefined): boolean =>
191 status !== null && status !== undefined && /^(in progress|en cours)$/i.test(status.trim())
192
193/** My Jira issues really in progress that no listed PR carries. */
194export const lonelyIssues = (snapshot: Snapshot): JiraInfo[] => {
195 const linkedKeys = new Set(snapshot.prs.map(row => row.jiraKey))
196 return snapshot.jiraInProgress.filter(issue => isWorkingStatus(issue.status) && !linkedKeys.has(issue.key))
197}
198
199const ciOf = (state: string | undefined): Ci => {
200 if (state === 'SUCCESS') return 'success'
201 if (state === 'FAILURE' || state === 'ERROR') return 'failure'
202 if (state === 'PENDING' || state === 'EXPECTED') return 'pending'
203 return 'none'
204}
205
206const loginOf = (who: Login): string => who?.login ?? 'ghost'
207
208const isPr = (node: RawPr | Record<string, never>): node is RawPr =>
209 typeof (node as RawPr).number === 'number'
210
211
212/** Whether a label is the `request-changes` review outcome (or `request change`). */
213export const isChangesLabel = (name: string): boolean => /^request[ -]?changes?$/i.test(name.trim())
214
215/** Whether a label is the `approved` review outcome. */
216export const isApprovedLabel = (name: string): boolean => /^approved?$/i.test(name.trim())
217
218/** The states of my own PRs that the review labels decide. */
219export const REASON = {
220 followUp: 'Review follow-up needed',
221 manualReview: 'Ready for manual review',
222 review: 'Review needed',
223} as const
224
225/** Classifies one pull request from the account's point of view. */
226export const classify = (raw: RawPr, me: string, role: Role): PrRow => {
227 const head = raw.commits.nodes[0]?.commit
228 const lastCommitAt = head?.committedDate ?? raw.createdAt
229 const ci = ciOf(head?.statusCheckRollup?.state)
230 const hasConflicts = raw.mergeable === 'CONFLICTING'
231 const labels = raw.labels.nodes.map(label => label.name)
232 const reviewers = raw.reviewRequests.nodes
233 .map(node => node.requestedReviewer?.login ?? node.requestedReviewer?.slug)
234 .filter((name): name is string => name !== undefined)
235 const opinions = raw.latestOpinionatedReviews.nodes
236 const approvals = opinions.filter(r => r.state === 'APPROVED').map(r => loginOf(r.author))
237 const changes = opinions.filter(r => r.state === 'CHANGES_REQUESTED')
238 const changesRequestedBy = changes.map(r => loginOf(r.author))
239 const author = loginOf(raw.author)
240 const openThreads = raw.reviewThreads.nodes.filter(thread => {
241 if (thread.isResolved || thread.isOutdated) return false
242 const last = thread.comments.nodes[0]
243 return last === undefined || loginOf(last.author) !== (role === 'author' ? me : author)
244 }).length
245
246 const base = {
247 id: `${raw.repository.nameWithOwner}#${raw.number}`,
248 repo: raw.repository.nameWithOwner,
249 number: raw.number,
250 title: raw.title,
251 url: raw.url,
252 author,
253 role,
254 isDraft: raw.isDraft,
255 ci,
256 hasConflicts,
257 openThreads,
258 reviewers,
259 approvals,
260 changesRequestedBy,
261 labels,
262 branch: raw.headRefName,
263 base: raw.baseRefName,
264 additions: raw.additions,
265 deletions: raw.deletions,
266 createdAt: raw.createdAt,
267 updatedAt: raw.updatedAt,
268 jiraKey: jiraKeyOf(raw.title, raw.headRefName),
269 }
270 const place = (lane: Lane, reason: string): PrRow => ({ ...base, lane, heldLane: null, reason })
271
272 const heldLabel = labels.find(isOnHoldText)
273 if (heldLabel !== undefined) return place('onhold', `Label “${heldLabel}”`)
274 const blockedLabel = labels.find(isBlockedText)
275 if (blockedLabel !== undefined) return place('blocked', `Label “${blockedLabel}”`)
276
277 if (role === 'author') {
278 if (raw.isDraft) {
279 if (ci === 'failure') return place('draft', 'Draft · CI failing')
280 if (hasConflicts) return place('draft', 'Draft · conflicts')
281 return place('draft', 'Draft')
282 }
283 if (hasConflicts) return place('blocked', `Conflicts with ${raw.baseRefName}`)
284 // The review workflow keeps one outcome label on the PR: it decides the state.
285 if (labels.some(isChangesLabel)) return place('action', REASON.followUp)
286 if (labels.some(isApprovedLabel)) return place('ready', REASON.manualReview)
287 return place('action', REASON.review)
288 }
289
290 if (raw.isDraft) return place('draft', `Draft by ${author}`)
291 const mine = raw.latestReviews.nodes.find(r => loginOf(r.author) === me)
292 const isRequested = reviewers.includes(me)
293 if (isRequested) {
294 return place('action', mine === undefined ? 'Review requested' : 'Re-review requested')
295 }
296 if (mine !== undefined) {
297 const after = mine.submittedAt !== null && lastCommitAt > mine.submittedAt
298 if (mine.state === 'APPROVED') return place('waiting', 'Approved by you · waiting for the merge')
299 if (after) return place('action', 'New commits since your review')
300 if (openThreads > 0 || mine.state === 'CHANGES_REQUESTED') {
301 return place('waiting', `Waiting for ${author}’s follow-up`)
302 }
303 return place('waiting', 'Commented by you')
304 }
305 if (role === 'assignee') return place('waiting', `Assigned · author ${author}`)
306 return place('waiting', `By ${author}`)
307}
308
309/** Every open pull request of the four searches, once each, the strongest role first. */
310export const collectPrs = (data: RawSearch): PrRow[] => {
311 const me = data.viewer.login
312 const seen = new Map<string, PrRow>()
313 const take = (nodes: (RawPr | Record<string, never>)[], role: Role): void => {
314 for (const node of nodes) {
315 if (!isPr(node)) continue
316 const id = `${node.repository.nameWithOwner}#${node.number}`
317 if (!seen.has(id)) seen.set(id, classify(node, me, role))
318 }
319 }
320 take(data.authored.nodes, 'author')
321 take(data.requested.nodes, 'reviewer')
322 take(data.reviewed.nodes, 'reviewer')
323 take(data.assigned.nodes, 'assignee')
324 return [...seen.values()]
325}
326
327/**
328 * A Jira status on hold parks any PR out of the way; a blocked status blocks
329 * my PR otherwise not in a worse lane.
330 */
331export const applyJira = (row: PrRow, jira: Record<string, JiraInfo>): PrRow => {
332 const issue = row.jiraKey === null ? undefined : jira[row.jiraKey]
333 if (issue === undefined) return row
334 if (isOnHoldText(issue.status)) return { ...row, lane: 'onhold', reason: `Jira “${issue.status}”` }
335 if (row.lane === 'blocked' || row.role !== 'author') return row
336 if (isBlockedText(issue.status)) return { ...row, lane: 'blocked', reason: `Jira “${issue.status}”` }
337 return row
338}
339
340const PRIORITY_RANK: Record<string, number> = {
341 blocker: 0,
342 highest: 0,
343 critical: 0.5,
344 'very high': 0.5,
345 high: 1,
346 medium: 2,
347 low: 3,
348 'very low': 4,
349 lowest: 5,
350}
351
352/** Lower is more urgent; an unknown priority ranks as medium. */
353export const priorityRank = (priority: string | null | undefined): number =>
354 priority === null || priority === undefined ? 2.5 : (PRIORITY_RANK[priority.toLowerCase()] ?? 2.5)
355
356/** Arrow and color of a Jira priority. */
357export const priorityMark = (priority: string): { glyph: string; color: string } => {
358 const rank = priorityRank(priority)
359 if (rank <= 0.5) return { glyph: '⏫', color: 'error' }
360 if (rank <= 1) return { glyph: '▲', color: 'warning' }
361 if (rank <= 2.5) return { glyph: '■', color: 'suggestion' }
362 return { glyph: '▼', color: 'inactive' }
363}
364
365/** Lane order, then Jira priority, then the most recently updated. */
366export const sortPrs = (rows: PrRow[], jira: Record<string, JiraInfo>): PrRow[] => {
367 const laneRank = (lane: Lane): number => LANES.findIndex(one => one.id === lane)
368 const prio = (row: PrRow): number =>
369 priorityRank(row.jiraKey === null ? null : jira[row.jiraKey]?.priority)
370 return [...rows].sort(
371 (a, b) =>
372 laneRank(a.lane) - laneRank(b.lane) ||
373 prio(a) - prio(b) ||
374 b.updatedAt.localeCompare(a.updatedAt),
375 )
376}
377
378/**
379 * Puts the PRs marked in progress in their lane, and gives the others back
380 * theirs. A mark holds while the PR's `updatedAt` is the one it was marked at:
381 * the PR's next change (a push, a label, a review) or its closing drops it.
382 */
383export const applyMarks = (rows: PrRow[], marks: Marks): { rows: PrRow[]; marks: Marks } => {
384 const kept: Marks = {}
385 const placed = rows.map(row => {
386 const lane = row.heldLane ?? row.lane
387 if (marks[row.id] !== row.updatedAt || lane === 'onhold') return { ...row, lane, heldLane: null }
388 kept[row.id] = row.updatedAt
389 return { ...row, lane: 'inprogress' as const, heldLane: lane }
390 })
391 return { rows: placed, marks: kept }
392}
393
394type JiraIssue = { key: string; fields?: Record<string, unknown> }
395
396type JiraIssues = JiraIssue[] | { nodes?: JiraIssue[]; pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } }
397
398type JiraAnswer = { issues?: JiraIssues; nextPageToken?: string; isLast?: boolean }
399
400/**
401 * Reads one page of the Atlassian connector's JQL answer. The connector has
402 * answered with `issues` as a list, under `data` or not, with `nextPageToken`
403 * and `isLast` beside it, and now answers with the list in `issues.nodes` and
404 * the next page's token in `issues.pageInfo.endCursor`.
405 * @param text - The connector's answer.
406 * @returns The issues, none when the answer holds no `issues`, and the token
407 * to pass as `nextPageToken` for the next page, null on the last page.
408 * @throws When the answer is not JSON or its `issues` holds no list.
409 */
410export const parseJiraPage = (text: string): { issues: JiraInfo[]; next: string | null } => {
411 const body = JSON.parse(text) as (JiraAnswer & { data?: JiraAnswer }) | null
412 const answer = body?.data ?? body ?? {}
413 const found = answer.issues ?? []
414 const issues = Array.isArray(found) ? found : found.nodes
415 if (!Array.isArray(issues)) throw new Error('the Jira answer holds no list of issues')
416 const pageInfo = Array.isArray(found) ? undefined : found.pageInfo
417 const next = pageInfo === undefined
418 ? answer.isLast === true ? null : answer.nextPageToken ?? null
419 : pageInfo.hasNextPage === true ? pageInfo.endCursor ?? null : null
420 const infos = issues.map(issue => {
421 const f = (issue.fields ?? {}) as {
422 summary?: string
423 status?: { name?: string; statusCategory?: { key?: string } }
424 priority?: { name?: string } | null
425 assignee?: { displayName?: string } | null
426 issuetype?: { name?: string }
427 }
428 return {
429 key: issue.key,
430 summary: f.summary ?? '',
431 status: f.status?.name ?? '?',
432 category: f.status?.statusCategory?.key ?? null,
433 priority: f.priority?.name ?? null,
434 assignee: f.assignee?.displayName ?? null,
435 type: f.issuetype?.name ?? null,
436 }
437 })
438 return { issues: infos, next }
439}
440
441/** Reads the desktop app's `list_sessions` answer. */
442export const parseSessions = (text: string): Omit<SessionInfo, 'repo'>[] => {
443 const rows = JSON.parse(text) as {
444 sessionId: string
445 title?: string
446 cwd?: string
447 isArchived?: boolean
448 isRunning?: boolean
449 prNumber?: number
450 lastActivityAt?: string
451 link?: string
452 }[]
453 return rows
454 .filter(row => row.isArchived !== true)
455 .map(row => ({
456 id: row.sessionId,
457 title: row.title ?? row.sessionId,
458 cwd: row.cwd ?? '',
459 prNumber: row.prNumber ?? null,
460 isRunning: row.isRunning === true,
461 lastActivityAt: row.lastActivityAt ?? '',
462 link: row.link ?? null,
463 }))
464}
465
466/** `owner/repo` from a Git remote URL. */
467export const repoOfRemote = (url: string): string | null => {
468 const match = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\s*$/i.exec(url.trim())
469 return match === null ? null : `${match[1]}/${match[2]}`
470}
471
472const sameRepo = (a: string | null, b: string | null): boolean =>
473 a !== null && b !== null && a.toLowerCase() === b.toLowerCase()
474
475/** The sessions working on a pull request: its number in its repository, or its Jira key. */
476export const sessionsOf = (row: PrRow, sessions: SessionInfo[]): SessionInfo[] =>
477 sessions.filter(
478 s =>
479 (s.prNumber === row.number && sameRepo(s.repo, row.repo)) ||
480 (row.jiraKey !== null && jiraKeyOf(s.title) === row.jiraKey),
481 )
482
483/** The unfinished Agent Context runs of a pull request. */
484export const runsOf = (row: PrRow, runs: RunInfo[]): RunInfo[] =>
485 runs.filter(
486 run =>
487 (sameRepo(run.repo, row.repo) && (run.pr === row.number || run.branch === row.branch)) ||
488 (row.jiraKey !== null && run.jiraKey === row.jiraKey),
489 )
490
491/** Counts per lane, for the status line and the pane's header. */
492export const laneCounts = (rows: PrRow[]): Record<Lane, number> => {
493 const counts: Record<Lane, number> = { blocked: 0, action: 0, inprogress: 0, ready: 0, waiting: 0, draft: 0, onhold: 0 }
494 for (const row of rows) counts[row.lane] += 1
495 return counts
496}
497
498/** The one status-line text; undefined when there is nothing open. */
499export const statusText = (snapshot: Snapshot): string | undefined => {
500 if (snapshot.prs.length === 0) return undefined
501 const counts = laneCounts(snapshot.prs)
502 const parts = LANES.filter(lane => lane.id !== 'draft' && lane.id !== 'onhold' && counts[lane.id] > 0).map(
503 lane => `${lane.glyph} ${counts[lane.id]}`,
504 )
505 return `PR ${parts.length === 0 ? `✎ ${counts.draft}` : parts.join(' ')}`
506}
507
508/** The PRs that entered the blocked or action lanes since the previous snapshot. */
509export const newlyUrgent = (before: Snapshot | null, after: Snapshot): PrRow[] => {
510 if (before === null) return []
511 const was = new Map(before.prs.map(row => [row.id, row.lane]))
512 return after.prs.filter(
513 row => (row.lane === 'blocked' || row.lane === 'action') && was.get(row.id) !== row.lane,
514 )
515}
516
517/** "3 min", "2 h", "4 d" since an ISO date or a timestamp. */
518export const ago = (now: number, when: string | number): string => {
519 const at = typeof when === 'number' ? when : Date.parse(when)
520 if (Number.isNaN(at)) return ''
521 const minutes = Math.max(0, Math.round((now - at) / 60000))
522 if (minutes < 1) return 'just now'
523 if (minutes < 60) return `${minutes} min`
524 const hours = Math.round(minutes / 60)
525 if (hours < 24) return `${hours} h`
526 return `${Math.round(hours / 24)} d`
527}
528
529/**
530 * The prompt a row's main action puts in the composer; none for parked work
531 * or for my PR ready for manual review, which the person reviews by hand.
532 */
533export const actionPrompt = (marked: PrRow): { label: string; text: string } | null => {
534 const row = { ...marked, lane: marked.heldLane ?? marked.lane }
535 if (row.lane === 'onhold' || row.lane === 'draft' || row.lane === 'ready') return null
536 if (row.role !== 'author' && row.lane === 'action') {
537 return { label: 'Review', text: `Review ${row.url}` }
538 }
539 if (row.hasConflicts) {
540 return { label: 'Resolve', text: `Resolve the conflicts of ${row.url} with ${row.base}` }
541 }
542 if (row.role === 'author' && row.reason === REASON.followUp) {
543 return { label: 'Follow-up', text: `Do the review follow-up of ${row.url}` }
544 }
545 if (row.role === 'author' && row.reason === REASON.review) {
546 return { label: 'Start the review', text: `Review ${row.url}` }
547 }
548 return { label: 'Resume', text: `Where does ${row.url} stand? Suggest the next step.` }
549}
550
551/**
552 * The button that moves my PR between draft and ready for review: my drafts
553 * become ready, and the ones ready for manual review go back to draft.
554 * @param row - The PR of the card, with its lane before any in-progress mark in `heldLane`.
555 * @returns The button's label and the state its press sets (`isReady` true marks
556 * the PR ready, false turns it back into a draft), or null when the card has no button.
557 */
558export const draftToggle = (row: PrRow): { label: string; isReady: boolean } | null => {
559 if (row.role !== 'author') return null
560 if (row.isDraft) return { label: 'Mark ready', isReady: true }
561 if ((row.heldLane ?? row.lane) === 'ready') return { label: 'Back to draft', isReady: false }
562 return null
563}
564
565/** A plain-text summary for the model-callable tool. */
566export const summary = (snapshot: Snapshot, now: number): string => {
567 const age = ago(now, snapshot.fetchedAt)
568 const lines = [`PR Radar of ${snapshot.login}, refreshed ${age === 'just now' ? age : `${age} ago`}.`]
569 for (const lane of LANES) {
570 const rows = snapshot.prs.filter(row => row.lane === lane.id)
571 if (rows.length === 0) continue
572 lines.push('', `## ${lane.label} (${rows.length})`)
573 for (const row of rows) {
574 const issue = row.jiraKey === null ? undefined : snapshot.jira[row.jiraKey]
575 const jira =
576 issue === undefined ? (row.jiraKey ?? '') : `${issue.key} ${issue.status} ${issue.priority ?? ''}`
577 const sessions = sessionsOf(row, snapshot.sessions).map(s => `session “${s.title}”`)
578 lines.push(
579 `- ${row.id} ${row.title} — ${row.reason}; CI ${row.ci}; ${jira}`.trim() +
580 (sessions.length > 0 ? `; ${sessions.join(', ')}` : '') +
581 ` <${row.url}>`,
582 )
583 }
584 }
585 const lonely = lonelyIssues(snapshot)
586 if (lonely.length > 0) {
587 lines.push('', '## Jira issues in progress without a PR')
588 for (const issue of lonely) lines.push(`- ${issue.key} ${issue.summary} — ${issue.status}`)
589 }
590 if (snapshot.runs.length > 0) {
591 lines.push('', '## Unfinished Agent Context work')
592 for (const run of snapshot.runs.slice(0, 15)) {
593 lines.push(
594 `- ${run.repo ?? '?'} ${run.pr === null ? (run.jiraKey ?? run.branch ?? '') : `#${run.pr}`} — ${run.workflow} (${run.status}, ${run.startedAt})`,
595 )
596 }
597 }
598 if (snapshot.errors.length > 0) lines.push('', `Errors: ${snapshot.errors.join('; ')}`)
599 return lines.join('\n')
600}
601types/index.d.ts 120 lines1/** Who has the ball on a pull request, in the order the pane draws the lanes. */
2export type Lane = 'blocked' | 'action' | 'inprogress' | 'ready' | 'waiting' | 'draft' | 'onhold'
3
4/** How the account relates to the pull request. */
5export type Role = 'author' | 'reviewer' | 'assignee'
6
7/** The head commit's combined check state. */
8export type Ci = 'success' | 'failure' | 'pending' | 'none'
9
10/** One Jira issue as the pane shows it. */
11export type JiraInfo = {
12 key: string
13 summary: string
14 status: string
15 /** Jira's status category key: `new`, `indeterminate` or `done`. */
16 category: string | null
17 priority: string | null
18 assignee: string | null
19 type: string | null
20}
21
22/** One open Claude desktop session. */
23export type SessionInfo = {
24 id: string
25 title: string
26 cwd: string
27 repo: string | null
28 prNumber: number | null
29 isRunning: boolean
30 lastActivityAt: string
31 link: string | null
32}
33
34/** The latest Agent Context run of one target that is not repository-complete. */
35export type RunInfo = {
36 runId: string
37 repo: string | null
38 pr: number | null
39 jiraKey: string | null
40 branch: string | null
41 workflow: string
42 status: string
43 startedAt: string
44 client: string | null
45 sessionId: string | null
46}
47
48/** One open pull request, classified. */
49export type PrRow = {
50 id: string
51 repo: string
52 number: number
53 title: string
54 url: string
55 author: string
56 role: Role
57 isDraft: boolean
58 lane: Lane
59 /** The lane it would sit in when marked in progress; null otherwise. */
60 heldLane: Lane | null
61 /** Why it sits in its lane, in French. */
62 reason: string
63 ci: Ci
64 hasConflicts: boolean
65 /** Unresolved, not outdated review threads whose last word is someone else's. */
66 openThreads: number
67 reviewers: string[]
68 approvals: string[]
69 changesRequestedBy: string[]
70 labels: string[]
71 branch: string
72 base: string
73 additions: number
74 deletions: number
75 createdAt: string
76 updatedAt: string
77 jiraKey: string | null
78}
79
80/** The state of one optional source. */
81export type SourceState = 'ok' | 'needs-permission' | 'unavailable' | 'error'
82
83/** Everything one refresh gathered; shared between sessions through `$.store`. */
84export type Snapshot = {
85 fetchedAt: number
86 login: string
87 prs: PrRow[]
88 jira: Record<string, JiraInfo>
89 /** Issues assigned to the account and in progress. */
90 jiraInProgress: JiraInfo[]
91 jiraSite: string | null
92 jiraState: SourceState
93 sessions: SessionInfo[]
94 sessionsState: SourceState
95 runs: RunInfo[]
96 errors: string[]
97}
98
99/** The PRs marked in progress: id -> the PR's `updatedAt` when marked; the next change clears it. */
100export type Marks = Record<string, string>
101
102/** Which pull requests the pane lists. */
103export type Filter = 'all' | 'mine' | 'reviews' | 'todo'
104
105declare module 'claude-code' {
106 interface PluginState {
107 'pr-radar': {
108 snapshot: Snapshot | null
109 isRefreshing: boolean
110 filter: Filter
111 /** The lanes drawn folded; drafts and on-hold work start folded. */
112 folded: Lane[]
113 /** The last tick's time: the pane's "now", so relative times move each minute. */
114 clock: number
115 /** Whether the pane is open; while it is closed the band above the prompt offers it. */
116 isPaneOpen: boolean
117 }
118 }
119}
120