SLOPSHOPPER

pr-feedback

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

newpanecommandtoastprocesstimer
v0.1.0no licenseupdated 2026-10-08felipeolaya-mw/pr-feedback
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-feedback
│ ┃ PR feedback ✕ › fix the failing auth test and add an audit log call │ ┃ — │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Pasar al ODD (0) ] [ Trabajar ] [ Hecho ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pr-feedback │ ⎿ pr-feedback: PR feedback pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PR feedback
— [ Pasar al ODD (0) ] [ Trabajar ] [ Hecho ] [ Quitar marca ]
README

pr-feedback

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.

Install

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

Use

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:

KeyAction
oPass 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"
wWork: fills the prompt asking Claude to address the selected comments, with no commit, push or GitHub reply, and marks them "en curso"
dMark the selected comments done
cClear the mark of the selected comments
rRefresh
vShow 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.

Limits

  • Reads the first 100 review threads, 50 reviews and 100 comments of a PR, with no pagination.
  • The o action relies on the odd plugin being installed.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .
Source 2 files
hooks/register.tsx 356 lines
1import { 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}
356
types/index.d.ts 50 lines
1export 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