SLOPSHOPPER

pr-radar

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…

panebandguardcommandtoaststatus
v0.3.8MITupdated 2026-10-26WBLD4A/pr-radar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-radar
│ ┃ PR Radar ✕ › fix the failing auth test and add an audit log call │ ┃ [ All ] To do My PRs Reviews just now ↻ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⚠ GitHub: JSON Parse error: Unexpected EOF ⎿ Read 6 lines │ ┃ ⚠ Agent Context: JSON Parse error: Unexpect… ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No open PR for this filter. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /prs │ ⎿ pr-radar: PR Radar opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PR Radar
[ All ] To do My PRs Reviews just now ↻ ⚠ GitHub: JSON Parse error: Unexpected EOF ⚠ Agent Context: JSON Parse error: Unexpected identifier "d… No open PR for this filter.
README

PR Radar

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.

Install

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

Use

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:

  1. Blocked
  2. Your turn
  3. In progress
  4. Ready for manual review
  5. Waiting on others
  6. Drafts
  7. On hold

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:

  • start the pull request's next step in a new conversation;
  • mark the pull request as in progress;
  • continue unfinished work;
  • copy the pull request's URL.

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.

Requirements

  • Required: the 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.
  • When the budget runs low, automatic refreshes wait for it to reset.
  • When the budget is spent, every refresh waits.
  • Meanwhile the pane keeps the last pull requests it got and says when it will ask again.
  • Required: python on PATH. The scripts in scripts/ find your local checkouts and read run records.
  • Optional: an Atlassian (Jira) connector in the session. It adds the Jira status and priority, and lists the issues you have in progress. Without it, cards leave Jira out. An automatic refresh skips any connector call that would ask for a permission, so no dialog opens on its own; /prs refresh does ask.
  • Optional: the desktop app's session tools. They link cards to the Claude sessions working on them. Without them, cards leave sessions out.
  • Optional: an Agent Context checkout, a private workflow repository whose run records (logs/**/runs/*.json) note unfinished agent work. PR Radar reads them to list that work. Without one, that section stays empty.

What it does on your machine

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;
  • a PowerShell command in a new Terminal tab, only when you start a conversation and the desktop app offers no session tool.

Files it reads:

  • the run records under <agentContextPath>/logs/**/runs/*.json;
  • the .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.

Notes for the directory review

  • Tools the mod calls itself (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.
  • The 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.
  • Environment variables read: 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.
  • Conversation data: the mod's only 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.
  • The command.run hook handles only the mod's own /prs command: it opens the pane, and with refresh refreshes it.

Options

Set these in the plugin's config menu. Each option is one row there.

OptionDefaultEffect
agentContextPathempty, meaning ~/agent-contextThe Agent Context checkout whose run records are read.
refreshMinutes5The minutes between two automatic refreshes. Open sessions share one snapshot.

Turn it off and on

claude plugin disable pr-radar@pr-radar
claude plugin enable pr-radar@pr-radar

Then run /reload-plugins in open sessions.

Development

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 .

Privacy

See the privacy policy.

License

MIT

Source 3 files
hooks/register.tsx 1108 lines
1import { 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}
1108
hooks/model.ts 601 lines
1import 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}
601
types/index.d.ts 120 lines
1/** 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