Side pane with the current branch's commit and PR review feedback; pick comments to work on or hand to the VIP ODD

A Claude Code mod that opens a side pane with the current branch, its last commit and its pull request, and lists the PR's review feedback: review threads (with file and line), review summaries and general comments.
From the pane you pick the comments to act on and either hand them to the task doc (ODD) flow or ask Claude to work on them. Both actions fill the prompt; nothing is sent until you press Enter.
The mod only reads GitHub. It never replies to, resolves or comments on a PR.
/plugin install pr-feedback --marketplace felipeolaya-mw/pr-feedback
Answer y to add the marketplace, then pick a scope. The repository is private, so your GitHub account needs access to it.
Requires the GitHub CLI (gh) logged in to an account that can read the repository's PRs.
The pane opens at session start when the branch has a PR (as a sidebar from 144 terminal columns). Open it any time with /pr-feedback.
Mark comments with [ ], then:
| Key | Action |
|---|---|
o | Pass to ODD: fills the prompt asking the odd skill to add the selected comments to the branch's task doc, and marks them "en ODD" |
w | Work: fills the prompt asking Claude to address the selected comments, with no commit, push or GitHub reply, and marks them "en curso" |
d | Mark the selected comments done |
c | Clear the mark of the selected comments |
r | Refresh |
v | Show or hide resolved threads |
Marks are kept per PR across sessions. The pane refreshes after each turn when the branch or commit moved, or when the data is older than two minutes.
o action relies on the odd plugin being installed.claude plugin validate .
claude plugin test .
claude --plugin-dir .hooks/register.tsx 356 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CommitInfo, FeedbackItem, Mark, PrInfo, Snapshot } from '../types'
5
6const PANE = 'pr-feedback'
7const TITLE = 'PR feedback'
8const STALE_MS = 120_000
9
10const snapshot = atom({ plugin: 'pr-feedback', key: 'snapshot' } as const, { status: 'idle', items: [] } as Snapshot)
11const selected = atom({ plugin: 'pr-feedback', key: 'selected' } as const, [] as string[])
12const marks = atom({ plugin: 'pr-feedback', key: 'marks' } as const, {} as Record<string, Mark>)
13const showResolved = atom({ plugin: 'pr-feedback', key: 'showResolved' } as const, false)
14
15const THREADS_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
16 repository(owner: $owner, name: $name) {
17 pullRequest(number: $number) {
18 reviewThreads(first: 100) {
19 nodes { id isResolved isOutdated path line originalLine
20 comments(first: 50) { nodes { author { login } body url } } }
21 }
22 reviews(first: 50) { nodes { id author { login } body state url } }
23 comments(first: 100) { nodes { id author { login } body url } }
24 }
25 }
26}`
27
28type Login = { login: string } | null
29type GqlComment = { id?: string; author: Login; body: string; url: string; state?: string }
30type GqlThread = {
31 id: string
32 isResolved: boolean
33 isOutdated: boolean
34 path: string
35 line: number | null
36 originalLine: number | null
37 comments: { nodes: GqlComment[] }
38}
39type GqlPull = {
40 reviewThreads: { nodes: GqlThread[] }
41 reviews: { nodes: GqlComment[] }
42 comments: { nodes: GqlComment[] }
43}
44
45let isRefreshing = false
46
47const who = (author: Login) => author?.login ?? 'ghost'
48
49async function run($: EngineInterface, argv: string[]) {
50 const out = await $.process.run(argv, { timeoutMs: 30_000 })
51 return { ok: out.exitCode === 0, text: out.stdout.trim(), err: out.stderr.trim() }
52}
53
54async function readGit($: EngineInterface): Promise<{ branch: string; commit: CommitInfo } | undefined> {
55 const branch = await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
56 if (!branch.ok) return undefined
57 const log = await run($, ['git', 'log', '-1', '--format=%h%x09%s%x09%cr'])
58 const [sha = '', subject = '', age = ''] = log.text.split('\t')
59 return { branch: branch.text, commit: { sha, subject, age } }
60}
61
62function toItems(pull: GqlPull): FeedbackItem[] {
63 const threads = pull.reviewThreads.nodes.flatMap<FeedbackItem>(thread => {
64 const [first] = thread.comments.nodes
65 if (first === undefined) return []
66
67 return [{
68 id: thread.id,
69 kind: 'thread',
70 author: who(first.author),
71 path: thread.path,
72 line: thread.line ?? thread.originalLine ?? undefined,
73 isResolved: thread.isResolved,
74 isOutdated: thread.isOutdated,
75 url: first.url,
76 comments: thread.comments.nodes.map(c => ({ author: who(c.author), body: c.body })),
77 }]
78 })
79 const reviews = pull.reviews.nodes
80 .filter(review => review.body.trim() !== '')
81 .map<FeedbackItem>(review => ({
82 id: review.id ?? review.url,
83 kind: 'review',
84 author: who(review.author),
85 isResolved: false,
86 isOutdated: false,
87 url: review.url,
88 comments: [{ author: who(review.author), body: `[${review.state ?? 'COMMENTED'}] ${review.body}` }],
89 }))
90 const comments = pull.comments.nodes.map<FeedbackItem>(comment => ({
91 id: comment.id ?? comment.url,
92 kind: 'comment',
93 author: who(comment.author),
94 isResolved: false,
95 isOutdated: false,
96 url: comment.url,
97 comments: [{ author: who(comment.author), body: comment.body }],
98 }))
99
100 return [...threads, ...reviews, ...comments]
101}
102
103async function refresh($: EngineInterface) {
104 if (isRefreshing) return
105 isRefreshing = true
106 try {
107 await update($, snapshot, (s): Snapshot => ({ ...s, status: 'loading' }))
108 const git = await readGit($)
109 if (git === undefined) {
110 await update($, snapshot, (): Snapshot => ({ status: 'no-repo', items: [] }))
111 return
112 }
113 const view = await run($, [
114 'gh', 'pr', 'view', '--json', 'number,title,url,state,reviewDecision,baseRefName',
115 ])
116 const fetchedAt = await $.clock.now()
117 if (!view.ok) {
118 const isMissing = /no pull requests found/i.test(view.err)
119 await update($, snapshot, (): Snapshot => ({
120 status: isMissing ? 'no-pr' : 'error',
121 error: isMissing ? undefined : view.err,
122 ...git,
123 items: [],
124 fetchedAt,
125 }))
126 return
127 }
128 const pr = JSON.parse(view.text) as PrInfo
129 const [, owner = '', name = ''] = /github\.com\/([^/]+)\/([^/]+)\/pull\//.exec(pr.url) ?? []
130 const gql = await run($, [
131 'gh', 'api', 'graphql',
132 '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `number=${pr.number}`,
133 '-f', `query=${THREADS_QUERY}`,
134 ])
135 if (!gql.ok) {
136 await update($, snapshot, (): Snapshot => ({ status: 'error', error: gql.err, ...git, pr, items: [], fetchedAt }))
137 return
138 }
139 const pull = (JSON.parse(gql.text) as { data: { repository: { pullRequest: GqlPull } } }).data.repository.pullRequest
140 const items = toItems(pull)
141 const stored = ((await $.store.get(`marks:${pr.url}`)) ?? {}) as Record<string, Mark>
142 await update($, marks, () => stored)
143 await update($, selected, ids => ids.filter(id => items.some(item => item.id === id)))
144 await update($, snapshot, (): Snapshot => ({ status: 'ready', ...git, pr, items, fetchedAt }))
145 } catch (error) {
146 await update($, snapshot, (s): Snapshot => ({ ...s, status: 'error', error: String(error) }))
147 } finally {
148 isRefreshing = false
149 }
150}
151
152async function setMarks($: EngineInterface, ids: string[], mark: Mark | undefined) {
153 const { pr } = await read($, snapshot)
154 if (pr === undefined) return
155 const next = await update($, marks, current => {
156 const copy = { ...current }
157 for (const id of ids) {
158 if (mark === undefined) delete copy[id]
159 else copy[id] = mark
160 }
161 return copy
162 })
163 await $.store.set(`marks:${pr.url}`, next)
164}
165
166function describe(item: FeedbackItem): string {
167 const where = item.path === undefined ? '' : ` ${item.path}${item.line === undefined ? '' : `:${item.line}`}`
168 const flags = [item.isResolved && 'resolved', item.isOutdated && 'outdated'].filter(Boolean).join(', ')
169 const head = `- [${item.kind}]${where} (@${item.author})${flags === '' ? '' : ` [${flags}]`} ${item.url}`
170 const body = item.comments
171 .map((c, i) => `${i === 0 ? '' : `↳ @${c.author}: `}${c.body}`.replace(/^/gm, ' > '))
172 .join('\n')
173
174 return `${head}\n${body}`
175}
176
177async function pickedItems($: EngineInterface) {
178 const [snap, ids] = await Promise.all([read($, snapshot), read($, selected)])
179
180 return { snap, picked: snap.items.filter(item => ids.includes(item.id)) }
181}
182
183async function fillPrompt($: EngineInterface, text: string) {
184 const box = await $.prompt.read()
185 await $.prompt.fill({ text: box.text.trim() === '' ? text : `\n\n${text}`, mode: box.text.trim() === '' ? 'replace' : 'append' })
186}
187
188async function sendToOdd($: EngineInterface) {
189 const { snap, picked } = await pickedItems($)
190 if (picked.length === 0 || snap.pr === undefined) {
191 await $.ui.toast('Selecciona al menos un comentario.')
192 return
193 }
194 await fillPrompt(
195 $,
196 [
197 `Usa el skill vipmed-odd:odd para agregar al ODD de la rama \`${snap.branch}\` este feedback del PR #${snap.pr.number} (${snap.pr.url}).`,
198 'Cada comentario es un ítem del checklist con su link; no escribas el doc a mano, va por el flujo de ODD. No respondas ni resuelvas nada en GitHub.',
199 '',
200 ...picked.map(describe),
201 ].join('\n'),
202 )
203 await setMarks($, picked.map(item => item.id), 'odd')
204 await update($, selected, () => [])
205}
206
207async function workOn($: EngineInterface) {
208 const { snap, picked } = await pickedItems($)
209 if (picked.length === 0 || snap.pr === undefined) {
210 await $.ui.toast('Selecciona al menos un comentario.')
211 return
212 }
213 await fillPrompt(
214 $,
215 [
216 `Atiende estos comentarios del PR #${snap.pr.number} (${snap.pr.url}) en la rama \`${snap.branch}\`.`,
217 'Cambia solo lo que piden y, si el ODD de la rama tiene el ítem, actualízalo por el flujo de ODD. Sin commit, push ni respuestas en GitHub.',
218 '',
219 ...picked.map(describe),
220 ].join('\n'),
221 )
222 await setMarks($, picked.map(item => item.id), 'working')
223 await update($, selected, () => [])
224}
225
226async function markDone($: EngineInterface) {
227 const ids = await read($, selected)
228 if (ids.length === 0) {
229 await $.ui.toast('Selecciona al menos un comentario.')
230 return
231 }
232 await setMarks($, ids, 'done')
233 await update($, selected, () => [])
234}
235
236const MARK_LABEL: Record<Mark, string> = { odd: 'en ODD', working: 'en curso', done: 'hecho' }
237const MARK_COLOR: Record<Mark, 'cyan' | 'yellow' | 'green'> = { odd: 'cyan', working: 'yellow', done: 'green' }
238
239export const register: Register = on => {
240 on('session.start', async ($, e, next) => {
241 await $.command.register({
242 name: 'pr-feedback',
243 description: 'Open the pane with the current commit, PR and its review feedback',
244 })
245 $.clock.after(1, async () => {
246 await refresh($)
247 const snap = await read($, snapshot)
248 if (snap.status === 'ready') await $.ui.open({ id: PANE, title: TITLE })
249 })
250
251 return next(e)
252 })
253
254 on('command.run', { command: 'pr-feedback' }, async $ => {
255 await $.ui.open({ id: PANE, title: TITLE })
256 $.clock.after(1, () => refresh($))
257
258 return { text: 'PR feedback pane opened.' }
259 })
260
261 on('turn.complete', async ($, e, next) => {
262 const done = await next(e)
263 $.clock.after(1, async () => {
264 const snap = await read($, snapshot)
265 const git = await readGit($)
266 const now = await $.clock.now()
267 const isMoved = git?.branch !== snap.branch || git?.commit.sha !== snap.commit?.sha
268 if (isMoved || now - (snap.fetchedAt ?? 0) > STALE_MS) await refresh($)
269 })
270
271 return done
272 })
273
274 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
275 const { Box, Text, Button } = $.ui.resolve(e)
276 const [snap, ids, marked, withResolved] = await Promise.all([
277 read($, snapshot),
278 read($, selected),
279 read($, marks),
280 read($, showResolved),
281 ])
282 const width = Math.max(20, (e.props.bodyColumns ?? 60) - 2)
283 const items = snap.items.filter(item => withResolved || !item.isResolved)
284 const hidden = snap.items.length - items.length
285 const toggle = (id: string) =>
286 update($, selected, list => (list.includes(id) ? list.filter(x => x !== id) : [...list, id]))
287
288 return (
289 <Box flexDirection="column" gap={1}>
290 <Box flexDirection="column">
291 <Text wrap="truncate-end">
292 <Text bold>{snap.branch ?? '—'}</Text>
293 {snap.commit && <Text dimColor>{` ${snap.commit.sha} ${snap.commit.subject} (${snap.commit.age})`}</Text>}
294 </Text>
295 {snap.pr && (
296 <Text wrap="truncate-end">
297 <Text color="magenta">{`#${snap.pr.number} `}</Text>
298 {`${snap.pr.title}`}
299 <Text dimColor>{` ${snap.pr.state} → ${snap.pr.baseRefName}${snap.pr.reviewDecision ? ` · ${snap.pr.reviewDecision}` : ''}`}</Text>
300 </Text>
301 )}
302 {snap.status === 'loading' && <Text dimColor>Cargando…</Text>}
303 {snap.status === 'no-repo' && <Text dimColor>El directorio de la sesión no es un repo git.</Text>}
304 {snap.status === 'no-pr' && <Text dimColor>Esta rama no tiene PR abierto.</Text>}
305 {snap.status === 'error' && <Text color="red" wrap="wrap">{snap.error ?? 'Error al leer el PR.'}</Text>}
306 </Box>
307
308 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
309 <Button key="odd" hotkey="o" variant="primary" onPress={() => sendToOdd($)}>
310 {`Pasar al ODD (${ids.length})`}
311 </Button>
312 <Button key="work" hotkey="w" onPress={() => workOn($)}>Trabajar</Button>
313 <Button key="done" hotkey="d" onPress={() => markDone($)}>Hecho</Button>
314 <Button key="clear" hotkey="c" onPress={() => setMarks($, ids, undefined)}>Quitar marca</Button>
315 <Button key="refresh" hotkey="r" onPress={() => refresh($)}>Refrescar</Button>
316 <Button key="resolved" hotkey="v" onPress={() => update($, showResolved, v => !v)}>
317 {withResolved ? 'Ocultar resueltos' : `Ver resueltos (${hidden})`}
318 </Button>
319 </Box>
320
321 {snap.status === 'ready' && items.length === 0 && <Text dimColor>Sin comentarios pendientes.</Text>}
322
323 {items.map(item => {
324 const mark = marked[item.id]
325 const isPicked = ids.includes(item.id)
326 const where = item.path === undefined ? item.kind : `${item.path}${item.line === undefined ? '' : `:${item.line}`}`
327
328 return (
329 <Box key={`item-${item.id}`} flexDirection="column">
330 <Box flexDirection="row" columnGap={1}>
331 <Button key={`sel-${item.id}`} plain onPress={() => toggle(item.id)}>
332 {isPicked ? '[x]' : '[ ]'}
333 </Button>
334 <Text wrap="truncate-middle" dimColor={item.isResolved || mark === 'done'}>
335 <Text color="blue">{where}</Text>
336 <Text dimColor>{` @${item.author}`}</Text>
337 {item.isOutdated && <Text dimColor> outdated</Text>}
338 {item.isResolved && <Text dimColor> resolved</Text>}
339 {mark && <Text color={MARK_COLOR[mark]}>{` · ${MARK_LABEL[mark]}`}</Text>}
340 </Text>
341 </Box>
342 {item.comments.map((comment, index) => (
343 <Box key={`c-${item.id}-${index}`} paddingLeft={4} width={width}>
344 <Text wrap="wrap" dimColor={index > 0 || mark === 'done'}>
345 {index === 0 ? comment.body.trim() : `↳ @${comment.author}: ${comment.body.trim()}`}
346 </Text>
347 </Box>
348 ))}
349 </Box>
350 )
351 })}
352 </Box>
353 )
354 })
355}
356types/index.d.ts 50 lines1export type FeedbackKind = 'thread' | 'review' | 'comment'
2
3export type FeedbackComment = { author: string; body: string }
4
5export type FeedbackItem = {
6 id: string
7 kind: FeedbackKind
8 author: string
9 path?: string
10 line?: number
11 isResolved: boolean
12 isOutdated: boolean
13 url: string
14 comments: FeedbackComment[]
15}
16
17export type PrInfo = {
18 number: number
19 title: string
20 url: string
21 state: string
22 reviewDecision: string
23 baseRefName: string
24}
25
26export type CommitInfo = { sha: string; subject: string; age: string }
27
28export type Snapshot = {
29 status: 'idle' | 'loading' | 'ready' | 'no-repo' | 'no-pr' | 'error'
30 error?: string
31 branch?: string
32 commit?: CommitInfo
33 pr?: PrInfo
34 items: FeedbackItem[]
35 fetchedAt?: number
36}
37
38export type Mark = 'odd' | 'working' | 'done'
39
40declare module 'claude-code' {
41 interface PluginState {
42 'pr-feedback': {
43 snapshot: Snapshot
44 selected: string[]
45 marks: Record<string, Mark>
46 showResolved: boolean
47 }
48 }
49}
50