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

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.
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.
#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»).
gh issue edit N --add-assignee @me.#número y título (borrador si lo es), head → base, estado de los checks y del merge.
gh pr merge N --squash --delete-branch.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.
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.
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.
hooks/register.tsx 280 lines1import { 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}
280hooks/github.ts 150 lines1import 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'
150types/index.d.ts 54 lines1export 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