SLOPSHOPPER

github-panel

Panel en vivo de GitHub con los issues y los PR abiertos del repositorio: tomar un issue, mergear un PR y reintentar checks

newpanecommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · github-panel
│ ┃ GitHub ✕ › fix the failing auth test and add an audit log call │ ┃ GitHub · hace 0 s … │ ┃ ⏺ Read(src/auth.ts) │ ┃ No se detectó un repositorio de GitHub ⎿ 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 │ │ › /github-panel │ ⎿ github-panel: Panel de GitHub abierto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · GitHub
GitHub · hace 0 s … No se detectó un repositorio de GitHub
README

github-panel

Panel en vivo para Claude Code con los issues y los pull requests abiertos del repositorio en que corre la sesión. Se abre solo al iniciar, o con /github-panel. Necesita la CLI gh con sesión iniciada (gh auth login).

El título es el repo (owner/repo, de gh repo view). Arriba ves «hace N s» desde el último refresco y un botón ↻ para refrescar ya; se actualiza solo cada 30 s, sin llamadas encimadas. Cada sección se pliega al hacer clic en su encabezado.

Instalar

claude plugin marketplace add jorgehsy/claude-mods
claude plugin install github-panel@jorge-mods --scope user

En Claude Code 2.1.275 o posterior también sirve: /plugin install github-panel --marketplace jorgehsy/claude-mods.

Issues

#número y título (clic abre GitHub), etiquetas con su color, asignado (@login o «sin asignar»), sub-issues hechos/total y el PR que lo cierra (o «sin PR»).

  • Tomarlo (sólo sin asignado): gh issue edit N --add-assignee @me.

Pull requests

#número y título (borrador si lo es), head → base, estado de los checks y del merge.

  • Merge (sólo si es mergeable, no es borrador y los checks no fallaron): pide «¿Mergear #N? Sí / No»; con Sí corre gh pr merge N --squash --delete-branch.
  • Reintentar checks (sólo si fallaron): busca la última corrida fallida de la rama y corre gh run rerun <id> --failed.

Mientras una acción corre, su botón muestra «…»; al terminar, el resultado o el error queda en la fila y se refresca.

Datos

Una sola llamada gh api graphql por refresco. Si falta gh, no hay sesión o el directorio no es un repo de GitHub, el panel muestra una línea con la causa.

Desarrollo

claude plugin validate plugins/github-panel
tsc -p plugins/github-panel
claude plugin test plugins/github-panel

La lectura del JSON y las reglas de los botones están en hooks/github.ts, con pruebas en hooks/github.test.ts.

Source 3 files
hooks/register.tsx 280 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Issue, Pr, Snapshot } from '../types'
5import {
6  QUERY, parseRepo, parseGraphql, ghError, canTake, canMerge, canRetry,
7  C, progressBar, checkStatus, mergeStatus, prIsBad, ago, labelFg, labelBg,
8} from './github.ts'
9
10const PANE = 'github-panel'
11const REFRESH_MS = 30_000
12
13const repo = atom({ plugin: 'github-panel', key: 'repo' } as const, '')
14const data = atom({ plugin: 'github-panel', key: 'data' } as const, null)
15const error = atom({ plugin: 'github-panel', key: 'error' } as const, '')
16const fetchedAt = atom({ plugin: 'github-panel', key: 'fetchedAt' } as const, 0)
17const now = atom({ plugin: 'github-panel', key: 'now' } as const, 0)
18const refreshing = atom({ plugin: 'github-panel', key: 'refreshing' } as const, false)
19const collapsed = atom({ plugin: 'github-panel', key: 'collapsed' } as const, { issues: false, prs: false })
20const busy = atom({ plugin: 'github-panel', key: 'busy' } as const, {})
21const confirm = atom({ plugin: 'github-panel', key: 'confirm' } as const, null)
22const notes = atom({ plugin: 'github-panel', key: 'notes' } as const, {})
23
24const title = (name: string) => (name ? name : 'GitHub')
25
26/** Corre `gh` en el directorio de la sesión; nunca rechaza. */
27async function gh($: EngineInterface, args: string[], timeoutMs = 20_000) {
28  try {
29    const res = await $.process.run(['gh', ...args], { timeoutMs })
30    return { ok: res.exitCode === 0, out: res.stdout, err: res.stderr.trim() }
31  } catch (err) {
32    return { ok: false, out: '', err: String(err) }
33  }
34}
35
36async function detectRepo($: EngineInterface): Promise<string> {
37  const res = await gh($, ['repo', 'view', '--json', 'nameWithOwner'], 10_000)
38  const name = res.ok ? parseRepo(res.out) : null
39  if (name) {
40    await update($, repo, () => name)
41    await update($, error, () => '')
42    return name
43  }
44  await update($, error, () => ghError(res.err, 'No se detectó un repositorio de GitHub'))
45  return ''
46}
47
48// Un solo fetch a la vez.
49let fetching = false
50
51async function refresh($: EngineInterface) {
52  if (fetching) return
53  fetching = true
54  await update($, refreshing, () => true)
55  try {
56    let name = (await read($, repo)) as string
57    if (!name) name = await detectRepo($)
58    if (name) {
59      const [owner, rname] = name.split('/')
60      const res = await gh($, ['api', 'graphql', '-F', `owner=${owner}`, '-F', `name=${rname}`, '-f', `query=${QUERY}`])
61      if (res.ok) {
62        try {
63          const snap = parseGraphql(res.out)
64          await update($, data, () => snap)
65          await update($, error, () => '')
66        } catch (err) {
67          await update($, error, () => ghError(String(err instanceof Error ? err.message : err)))
68        }
69      } else {
70        await update($, error, () => ghError(res.err))
71      }
72    }
73    const t = await $.clock.now()
74    await update($, fetchedAt, () => t)
75    await update($, now, () => t)
76  } catch (err) {
77    $.ui.log(`github-panel: no se pudo refrescar: ${String(err)}`, { to: 'debug' })
78  } finally {
79    fetching = false
80    await update($, refreshing, () => false)
81  }
82}
83
84const setNote = ($: EngineInterface, key: string, text: string) =>
85  update($, notes, (all: Record<string, string>) => ({ ...all, [key]: text }))
86
87/** Corre una acción con el botón en «…», deja el resultado en la fila y refresca. */
88async function act($: EngineInterface, busyKey: string, noteKey: string, run: () => Promise<string>) {
89  await update($, busy, (all: Record<string, boolean>) => ({ ...all, [busyKey]: true }))
90  try {
91    await setNote($, noteKey, await run())
92  } catch (err) {
93    await setNote($, noteKey, `Error: ${ghError(String(err))}`)
94  } finally {
95    await update($, busy, (all: Record<string, boolean>) => ({ ...all, [busyKey]: false }))
96  }
97  await refresh($)
98}
99
100const take = ($: EngineInterface, i: Issue) =>
101  act($, `take-${i.number}`, `issue-${i.number}`, async () => {
102    const r = await gh($, ['issue', 'edit', String(i.number), '--add-assignee', '@me'])
103    return r.ok ? `Tomaste el #${i.number}` : `Error: ${ghError(r.err)}`
104  })
105
106const merge = ($: EngineInterface, p: Pr) =>
107  act($, `merge-${p.number}`, `pr-${p.number}`, async () => {
108    await update($, confirm, () => null)
109    const r = await gh($, ['pr', 'merge', String(p.number), '--squash', '--delete-branch'], 60_000)
110    return r.ok ? `#${p.number} mergeado` : `Error: ${ghError(r.err)}`
111  })
112
113const retry = ($: EngineInterface, p: Pr) =>
114  act($, `retry-${p.number}`, `pr-${p.number}`, async () => {
115    const l = await gh($, ['run', 'list', '--branch', p.head, '--status', 'failure', '--limit', '1', '--json', 'databaseId'])
116    if (!l.ok) return `Error: ${ghError(l.err)}`
117    let id: number | undefined
118    try { id = (JSON.parse(l.out) as { databaseId?: number }[])[0]?.databaseId } catch { /* sin JSON, sin corrida */ }
119    if (id === undefined) return 'No hay una corrida fallida para reintentar'
120    const r = await gh($, ['run', 'rerun', String(id), '--failed'])
121    return r.ok ? `Reintentando la corrida ${id}` : `Error: ${ghError(r.err)}`
122  })
123
124export const register: Register = on => {
125  on('session.start', async ($, e, next) => {
126    await $.command.register({ name: 'github-panel', description: 'Abre el panel de issues y PR de GitHub' })
127    // Abre en cuanto arranca la sesión; detectar el repo y el primer fetch no lo frenan.
128    void (async () => {
129      try {
130        await $.ui.open({ id: PANE, title: title('') })
131        const name = await detectRepo($)
132        if (name) await $.ui.open({ id: PANE, title: title(name) })
133        await refresh($)
134      } catch (err) {
135        $.ui.log(`github-panel: no arrancó: ${String(err)}`, { to: 'debug' })
136      }
137    })()
138    // Cada segundo mueve el «hace N s»; cada 30 s vuelve a leer.
139    $.clock.every(1000, async () => {
140      const t = await $.clock.now()
141      await update($, now, () => t)
142      const at = (await read($, fetchedAt)) as number
143      if (!fetching && t - at >= REFRESH_MS) await refresh($)
144    })
145    return next(e)
146  })
147
148  on('command.run', { command: 'github-panel' }, async $ => {
149    await $.ui.open({ id: PANE, title: title((await read($, repo)) as string) })
150    void refresh($)
151    return { text: 'Panel de GitHub abierto.' }
152  })
153
154  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
155    const els = $.ui.resolve(e)
156    const { Box, Text, Button, Link } = els as any
157    const name = (await read($, repo)) as string
158    const snap = (await read($, data)) as Snapshot | null
159    const err = (await read($, error)) as string
160    const t = (await read($, now)) as number
161    const at = (await read($, fetchedAt)) as number
162    const isRefreshing = (await read($, refreshing)) as boolean
163    const fold = (await read($, collapsed)) as { issues: boolean; prs: boolean }
164    const working = (await read($, busy)) as Record<string, boolean>
165    const asking = (await read($, confirm)) as number | null
166    const msgs = (await read($, notes)) as Record<string, string>
167
168    const toggle = (k: 'issues' | 'prs') => update($, collapsed, (c: { issues: boolean; prs: boolean }) => ({ ...c, [k]: !c[k] }))
169    const noteLine = (key: string) => {
170      const m = msgs[key]
171      return m ? <Text color={m.startsWith('Error') ? C.bad : C.ok} wrap="truncate-end">{m}</Text> : null
172    }
173    const btn = (key: string, label: string, onPress: () => void, variant?: 'primary') => (
174      <Button key={key} label={working[key] ? '…' : label} variant={variant} onPress={() => { if (!working[key]) onPress() }} />
175    )
176
177    const labels = (i: Issue) => i.labels.map(l => (
178      <Text key={l.name} backgroundColor={labelBg(l.color)} color={labelFg(l.color)}>{` ${l.name} `}</Text>
179    ))
180
181    // Terminal: botones siempre a la vista. Otras superficies: se revelan al pasar el puntero,
182    // salvo que haya confirmación, trabajo en curso o un mensaje en esa tarjeta.
183    const onTerminal = e.surface === 'terminal'
184    const actions = (id: string, pinned: boolean, kids: any[]) => {
185      const shown = kids.filter(Boolean)
186      if (shown.length === 0) return null
187      const always = onTerminal || pinned
188      return (
189        <Box flexDirection="row" gap={1} alignSelf="center" display={always ? 'flex' : 'none'} hover={always ? undefined : { display: 'flex' }}>
190          {shown}
191        </Box>
192      )
193    }
194    const card = (id: string, bad: boolean, kids: any) => (
195      <Box
196        key={id} flexDirection="row" gap={1} paddingX={1} marginBottom={1}
197        borderStyle="round" borderColor={bad ? C.borderBad : C.border}
198        hover={{ borderColor: bad ? C.bad : C.borderHover }}
199      >
200        {kids}
201      </Box>
202    )
203    const status = (s: { glyph: string; text: string; color?: string; dim?: boolean }) => (
204      <Text color={s.color} dimColor={s.dim}>{s.glyph ? `${s.glyph} ${s.text}` : s.text}</Text>
205    )
206
207    const issueRow = (i: Issue) => {
208      const bar = progressBar(i.subDone, i.subTotal)
209      const pinned = working[`take-${i.number}`] === true || !!msgs[`issue-${i.number}`]
210      return card(`issue-${i.number}`, false, [
211        <Box key="main" flexDirection="column" flexGrow={1}>
212          <Text wrap="truncate-end"><Text color={C.muted}>#{i.number}</Text> <Link href={i.url}>{i.title}</Link></Text>
213          <Box flexDirection="row" gap={2} flexWrap="wrap">
214            {i.labels.length > 0 && <Box flexDirection="row" gap={1}>{labels(i)}</Box>}
215            {i.assignees.length ? <Text>● {i.assignees.map(a => `@${a}`).join(' ')}</Text> : <Text dimColor>○ sin asignar</Text>}
216            {bar.label !== '' && <Text><Text color={C.ok}>{bar.filled}</Text><Text dimColor>{bar.empty}</Text> {bar.label}</Text>}
217            {i.prNumber !== undefined ? <Text>⇄ #{i.prNumber}</Text> : <Text dimColor>sin PR</Text>}
218          </Box>
219          {noteLine(`issue-${i.number}`)}
220        </Box>,
221        actions(`issue-${i.number}`, pinned, [canTake(i) && btn(`take-${i.number}`, 'Tomarlo', () => take($, i))]),
222      ])
223    }
224
225    const prRow = (p: Pr) => {
226      const asked = asking === p.number
227      const pinned = asked || working[`merge-${p.number}`] === true || working[`retry-${p.number}`] === true || !!msgs[`pr-${p.number}`]
228      return card(`pr-${p.number}`, prIsBad(p), [
229        <Box key="main" flexDirection="column" flexGrow={1}>
230          <Text wrap="truncate-end">
231            <Text color={C.muted}>#{p.number}</Text> <Link href={p.url}>{p.title}</Link>
232            {p.isDraft && <Text dimColor> borrador</Text>}
233          </Text>
234          <Box flexDirection="row" gap={2} flexWrap="wrap">
235            <Text dimColor>{p.head} → {p.base}</Text>
236            {status(checkStatus(p.checks))}
237            {status(mergeStatus(p.mergeable))}
238          </Box>
239          {asked && (
240            <Box flexDirection="row" gap={1}>
241              <Text bold>¿Mergear #{p.number}?</Text>
242              {btn(`merge-${p.number}`, 'Sí', () => merge($, p), 'primary')}
243              <Button key={`no-${p.number}`} label="No" onPress={() => update($, confirm, () => null)} />
244            </Box>
245          )}
246          {noteLine(`pr-${p.number}`)}
247        </Box>,
248        actions(`pr-${p.number}`, pinned, [
249          canMerge(p) && !asked && btn(`merge-${p.number}`, 'Merge', () => update($, confirm, () => p.number), 'primary'),
250          canRetry(p) && btn(`retry-${p.number}`, 'Reintentar', () => retry($, p)),
251        ]),
252      ])
253    }
254
255    const section = (k: 'issues' | 'prs', head: string, n: number, body: any, emptyText: string) => (
256      <Box flexDirection="column">
257        <Button key={`sec-${k}`} plain onPress={() => toggle(k)}>
258          {`${fold[k] ? '▸' : '▾'} ${head} `}<Text backgroundColor={C.pill}>{` ${n} `}</Text>
259        </Button>
260        {!fold[k] && (n === 0 ? <Text dimColor>{emptyText}</Text> : body)}
261      </Box>
262    )
263
264    return (
265      <Box flexDirection="column" gap={1} paddingX={1}>
266        <Box flexDirection="row" gap={1}>
267          <Box flexGrow={1}>
268            <Text wrap="truncate-end"><Text bold color={C.accent}>{name || 'GitHub'}</Text> <Text dimColor>· {ago(t, at)}</Text></Text>
269          </Box>
270          <Button key="refresh" plain label={isRefreshing ? '…' : '↻'} onPress={() => { void refresh($) }} />
271        </Box>
272        {err !== '' && <Text color={C.bad} wrap="truncate-end">{err}</Text>}
273        {snap === null && err === '' && <Text dimColor>Leyendo GitHub…</Text>}
274        {snap !== null && section('issues', 'Issues', snap.issues.length, <Box flexDirection="column">{snap.issues.map(issueRow)}</Box>, 'Sin issues abiertos')}
275        {snap !== null && section('prs', 'Pull requests', snap.prs.length, <Box flexDirection="column">{snap.prs.map(prRow)}</Box>, 'Sin PR abiertos')}
276      </Box>
277    )
278  })
279}
280
hooks/github.ts 150 lines
1import type { Checks, Issue, Mergeable, Pr, Snapshot } from '../types'
2
3/** La consulta: una sola llamada trae issues y PR. */
4export const QUERY = `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){
5issues(states:OPEN,first:50,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{number title url labels(first:10){nodes{name color}} assignees(first:5){nodes{login}} subIssuesSummary{total completed} closedByPullRequestsReferences(first:3){nodes{number url}}}}
6pullRequests(states:OPEN,first:30,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{number title url isDraft headRefName baseRefName mergeable commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}}`
7
8type Nodes<T> = { nodes?: (T | null)[] | null } | null | undefined
9const list = <T>(c: Nodes<T>): T[] => (c?.nodes ?? []).filter((x): x is T => x != null)
10
11/** «owner/repo» de la salida de `gh repo view --json nameWithOwner`; null si no es válida. */
12export function parseRepo(stdout: string): string | null {
13  try {
14    const v = (JSON.parse(stdout) as { nameWithOwner?: unknown }).nameWithOwner
15    return typeof v === 'string' && /^[^/\s]+\/[^/\s]+$/.test(v) ? v : null
16  } catch {
17    return null
18  }
19}
20
21/** Una línea legible del error de `gh`, con las causas comunes en español. */
22export function ghError(stderr: string, fallback = 'No se pudo leer GitHub'): string {
23  const s = stderr.trim()
24  if (/not found|command not found|ENOENT|no such file/i.test(s) && /gh/i.test(s)) return 'No se encontró la CLI gh'
25  if (/not logged|gh auth login|authentication/i.test(s)) return 'gh no tiene sesión: corre gh auth login'
26  if (/not a git repository|no git remotes|none of the git remotes|could not determine/i.test(s)) {
27    return 'Este directorio no es un repositorio de GitHub'
28  }
29  const first = s.split('\n').find(l => l.trim() !== '')
30  return first ? first.trim().slice(0, 140) : fallback
31}
32
33export function checksOf(state: string | null | undefined): Checks {
34  switch (state) {
35    case 'SUCCESS': return 'ok'
36    case 'FAILURE': case 'ERROR': return 'failed'
37    case 'PENDING': case 'EXPECTED': return 'running'
38    default: return 'none'
39  }
40}
41
42const mergeableOf = (m: unknown): Mergeable => (m === 'MERGEABLE' || m === 'CONFLICTING' ? m : 'UNKNOWN')
43
44type RawIssue = {
45  number: number; title: string; url: string
46  labels?: Nodes<{ name: string; color: string }>
47  assignees?: Nodes<{ login: string }>
48  subIssuesSummary?: { total?: number; completed?: number } | null
49  closedByPullRequestsReferences?: Nodes<{ number: number }>
50}
51type RawPr = {
52  number: number; title: string; url: string; isDraft?: boolean
53  headRefName: string; baseRefName: string; mergeable?: string
54  commits?: Nodes<{ commit?: { statusCheckRollup?: { state?: string } | null } | null }>
55}
56
57/** Pasa la respuesta de `gh api graphql` al modelo del panel; lanza si trae errores o no es el JSON esperado. */
58export function parseGraphql(stdout: string): Snapshot {
59  const root = JSON.parse(stdout) as {
60    data?: { repository?: { issues?: Nodes<RawIssue>; pullRequests?: Nodes<RawPr> } | null }
61    errors?: { message?: string }[]
62  }
63  const repo = root.data?.repository
64  if (!repo) throw new Error(root.errors?.[0]?.message ?? 'Respuesta sin repositorio')
65  const issues: Issue[] = list(repo.issues).map(i => ({
66    number: i.number,
67    title: i.title,
68    url: i.url,
69    labels: list(i.labels).map(l => ({ name: l.name, color: l.color })),
70    assignees: list(i.assignees).map(a => a.login),
71    subTotal: i.subIssuesSummary?.total ?? 0,
72    subDone: i.subIssuesSummary?.completed ?? 0,
73    prNumber: list(i.closedByPullRequestsReferences)[0]?.number,
74  }))
75  const prs: Pr[] = list(repo.pullRequests).map(p => ({
76    number: p.number,
77    title: p.title,
78    url: p.url,
79    isDraft: p.isDraft === true,
80    head: p.headRefName,
81    base: p.baseRefName,
82    mergeable: mergeableOf(p.mergeable),
83    checks: checksOf(list(p.commits)[0]?.commit?.statusCheckRollup?.state),
84  }))
85  return { issues, prs }
86}
87
88// ── Reglas de los botones ────────────────────────────────────────────────────
89export const canTake = (i: Issue) => i.assignees.length === 0
90
91/** Merge sólo si es mergeable, no es borrador y los checks no fallaron. */
92export const canMerge = (p: Pr) => p.mergeable === 'MERGEABLE' && !p.isDraft && p.checks !== 'failed'
93
94export const canRetry = (p: Pr) => p.checks === 'failed'
95
96// ── Textos ───────────────────────────────────────────────────────────────────
97
98/** «hace 5 s», «hace 2 min»; «—» si nunca se leyó. */
99export function ago(now: number, at: number): string {
100  if (!at) return '—'
101  const s = Math.max(0, Math.round((now - at) / 1000))
102  return s < 60 ? `hace ${s} s` : `hace ${Math.floor(s / 60)} min`
103}
104
105/** Negro o blanco, el que se lea sobre el color de la etiqueta (hex sin #). */
106export function labelFg(hex: string): string {
107  const h = /^[0-9a-f]{6}$/i.test(hex) ? hex : '888888'
108  const [r, g, b] = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16))
109  return 0.299 * r! + 0.587 * g! + 0.114 * b! > 150 ? '#000000' : '#FFFFFF'
110}
111
112export const labelBg = (hex: string) => (/^[0-9a-f]{6}$/i.test(hex) ? `#${hex}` : '#888888')
113
114// ── Dibujo: piezas puras ─────────────────────────────────────────────────────
115export const C = {
116  ok: '#6cc483', bad: '#e5675f', warn: '#d9a54a', muted: '#7d7b73', accent: '#d97757',
117  border: '#3a3936', borderHover: '#6b6a65', borderBad: '#5a3533', pill: '#2a2a28',
118} as const
119
120/** Barra de sub-issues: hasta 10 celdas, `▰` hechas y `▱` por hacer. */
121export function progressBar(done: number, total: number): { filled: string; empty: string; label: string } {
122  if (!(total > 0)) return { filled: '', empty: '', label: '' }
123  const d = Math.min(Math.max(done, 0), total)
124  const cells = Math.min(total, 10)
125  const f = Math.round((d / total) * cells)
126  return { filled: '▰'.repeat(f), empty: '▱'.repeat(cells - f), label: `${d}/${total}` }
127}
128
129export type Status = { glyph: string; text: string; color?: string; dim?: boolean }
130
131export function checkStatus(c: Checks): Status {
132  switch (c) {
133    case 'ok': return { glyph: '✓', text: 'checks', color: C.ok }
134    case 'failed': return { glyph: '✗', text: 'checks fallaron', color: C.bad }
135    case 'running': return { glyph: '◌', text: 'checks corriendo', color: C.warn }
136    default: return { glyph: '', text: 'sin checks', dim: true }
137  }
138}
139
140export function mergeStatus(m: Mergeable): Status {
141  switch (m) {
142    case 'MERGEABLE': return { glyph: '✓', text: 'mergeable', color: C.ok }
143    case 'CONFLICTING': return { glyph: '⚠', text: 'conflictos', color: C.warn }
144    default: return { glyph: '', text: 'calculando', dim: true }
145  }
146}
147
148/** La tarjeta de un PR se pinta rojiza si fallaron los checks o hay conflictos. */
149export const prIsBad = (p: Pr) => p.checks === 'failed' || p.mergeable === 'CONFLICTING'
150
types/index.d.ts 54 lines
1export type Label = { name: string; color: string }
2
3export type Issue = {
4  number: number
5  title: string
6  url: string
7  labels: Label[]
8  assignees: string[]
9  subTotal: number
10  subDone: number
11  /** Número del PR que lo cierra, si hay uno. */
12  prNumber?: number
13}
14
15export type Checks = 'ok' | 'failed' | 'running' | 'none'
16export type Mergeable = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
17
18export type Pr = {
19  number: number
20  title: string
21  url: string
22  isDraft: boolean
23  head: string
24  base: string
25  mergeable: Mergeable
26  checks: Checks
27}
28
29export type Snapshot = { issues: Issue[]; prs: Pr[] }
30
31declare module 'claude-code' {
32  interface PluginState {
33    'github-panel': {
34      /** owner/repo del directorio de la sesión; vacío si no se detectó. */
35      repo: string
36      data: Snapshot | null
37      /** Mensaje de una línea cuando no se pudo leer GitHub. */
38      error: string
39      /** Hora (ms) del último refresco; 0 si no hubo. */
40      fetchedAt: number
41      /** Hora del panel, al segundo: mueve el «hace N s». */
42      now: number
43      refreshing: boolean
44      collapsed: { issues: boolean; prs: boolean }
45      /** Botones con una acción en curso, por clave (`take-12`, `merge-3`, `retry-3`). */
46      busy: Record<string, boolean>
47      /** PR que espera la confirmación del merge. */
48      confirm: number | null
49      /** Resultado o error de la última acción de cada fila (`issue-12`, `pr-3`). */
50      notes: Record<string, string>
51    }
52  }
53}
54