SLOPSHOPPER

checks

Verifiche: i test veloci a fine risposta e la CI su GitHub, in un pannello e nella riga di stato

newpaneguardcommandtoaststatus
v0.2.0MITupdated 2026-10-12Wowbagger1994/claude-mods/mods/checks
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · checks
│ ┃ Verifiche ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Verifiche g: ↻ aggiorna la CI │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ │ ⏺ Update(src/auth.ts) │ ┃ │ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ ▍Test locali nessun test trovato │ ⏺ Bash(bun test) │ ┃ │ Nessun test veloce trovato. Scrivi i │ ⎿ 3 pass, 1 fail │ ┃ │ comandi in .claude/checks.json del repo: │ │ ┃ │ {"checks": [{"label": "unit", "command": │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ "…"}]} │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ▍CI │ │ ┃ ╰──────────────────────────────────────────╯ › /checks │ ⎿ checks: Verifiche aperte: e esegue i test veloci, g aggiorna la │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Verifiche
◆ Verifiche g: ↻ aggiorna la CI ╭──────────────────────────────────────────────────────────╮ │ │ │ │ │ ▍Test locali nessun test trovato │ │ Nessun test veloce trovato. Scrivi i comandi in │ │ .claude/checks.json del repo: {"checks": [{"label": │ │ "unit", "command": "…"}]} │ │ │ │ ▍CI │ ╰──────────────────────────────────────────────────────────╯
README

checks

Un pannello «Verifiche» con i test veloci, lanciati a fine risposta nei repository che Claude ha modificato, e lo stato della CI su GitHub, così sai subito se una modifica ha rotto qualcosa.

Come si usa

/checks apre il pannello. Ogni repository ha una scheda con il bordo del colore del suo esito: rosso se qualcosa è fallito, giallo se qualcosa è in corso, verde se tutto è passato, grigio se non c'è ancora nessun risultato. In alto nella scheda ci sono il nome, il ramo e un'etichetta colorata: «✗ 2 da sistemare», «◌ in corso» o «✓ tutto verde». Con più repository la stessa etichetta, per tutti insieme, compare anche accanto al titolo del pannello.

Nella scheda ci sono due sezioni:

  • Test locali: da dove vengono i comandi, una barra colorata (verde i passati, rosso i falliti) con il conteggio e da quanto tempo è stato fatto l'ultimo giro. Poi un comando per riga, con il tempo che ha impiegato. Sotto un test fallito ci sono le ultime 8 righe del suo output, dietro una linea rossa. Mentre i test girano, la barra gialla mostra a che punto è il giro, per esempio «in corso: unit (2 di 3)», e i risultati precedenti restano visibili ma attenuati.
  • CI: la PR o il commit, la barra con il conteggio dei controlli (in giallo quelli in corso). I controlli falliti e quelli in corso hanno una riga ciascuno, con «apri» e «Chiedi a Claude». I passati e i saltati stanno in fondo, affiancati su una sola riga. Il nome del workflow compare davanti a ogni controllo solo se i workflow sono più di uno.
TastoCosa fa
e«▶ Esegui»: lancia di nuovo i test veloci del primo repository
gAggiorna la CI di tutti i repository
c«Chiedi a Claude» sul primo errore mostrato (è il pulsante evidenziato)

Senza tasto: «Esegui» degli altri repository, «Chiedi a Claude» sugli altri errori, «apri» porta al controllo su GitHub.

Riga di stato: ✓ test 4/4 · CI ✓ 2/2 · /checks, oppure ✗ test 1/4 · CI ⏳ 1/3 · /checks. Avvisi (toast), solo a pannello chiuso: «✗ shop: unit — /checks», «✓ shop: i test sono di nuovo verdi», «✗ CI di shop: lint — /checks, poi c per chiedere a Claude», «✓ CI di shop: tutto verde».

«Chiedi a Claude» manda nella conversazione un messaggio con il comando, l'esito e la parte finale dell'output (per la CI, del log), e chiede la causa e una correzione, senza commit né push.

Cosa fa da sola

  • All'avvio della sessione: se la cartella della sessione è in un repository git, lo aggiunge e legge la sua CI.
  • Dopo ogni Edit o Write: il repository del file entra nel pannello e viene segnato come modificato.
  • A fine risposta di Claude (non dei subagent): lancia i test dei repository modificati, uno dopo l'altro, e rilegge la CI se l'ultima lettura ha più di un minuto.
  • Dopo un git push lanciato da Bash: dopo 15 secondi rilegge la CI di tutti i repository del pannello. Ogni volta che la CI ha controlli in corso, la rilegge dopo 30 secondi.

Quali test lancia, in quest'ordine:

  1. quelli scritti in .claude/checks.json del repository: {"checks": [{"label": "unit", "command": "…"}]};
  2. altrimenti i passi veloci del primo file in .github/workflows/ (in ordine alfabetico) che ne ha: righe run: di una riga che cominciano con python3, python, pytest, go, npm, npx, bun, node, cargo, make, ruff o mypy. Salta i workflow con deploy, release o publish nel nome, e i passi con parole come docker, install, ssh, curl, build, push, secrets. Salta per intero i job che installano dipendenze che qui potrebbero mancare: sempre quelli con pip install, uv sync, poetry install, pipenv install o sync, conda install o env create, bundle install, composer install, apt install o brew install; quelli con npm ci, npm install, yarn install, pnpm install o bun install solo se nel repository manca node_modules. python diventa python3;
  3. altrimenti il test solito del progetto: go test ./... con go.mod; lo script test di package.json (bun run test con un lockfile di bun, se no npm test --silent); python3 -m pytest -q con pytest.ini o conftest.py; cargo test --quiet con Cargo.toml.

Cosa legge e cosa manda

  • Legge .claude/checks.json, i file in .github/workflows/, package.json e l'elenco dei file in cima al repository, e guarda se c'è la cartella node_modules.
  • Esegue i test con /bin/bash -c <comando> dalla cima del repository: sono comandi scritti nei file del repository, lanciati sul tuo computer senza chiedere.
  • Lancia git -C <cartella> rev-parse --show-toplevel, branch --show-current e rev-parse HEAD.
  • Per la CI lancia gh pr view --json number,url,title, gh pr checks <n> --json name,bucket,link,workflow, senza PR gh run list --commit <sha> --json …, e per «Chiedi a Claude» gh run view <id> --log-failed.
  • Non chiama modelli: il testo arriva a Claude solo quando premi «Chiedi a Claude». Prima oscura i token con prefisso noto (ghp_, gho_, ghs_, github_pat_, ya29., 1//, ntok_, nins_, ngat_, sk-, xoxb-…, AKIA) e ogni sequenza di 32 o più lettere, cifre e _-+= che mescola lettere e cifre. I percorsi restano leggibili: la / interrompe la sequenza. Anche un hash lungo diventa [oscurato].
  • Non usa lo stato della sessione: tutto sta in memoria e sparisce quando la sessione finisce.

Requisiti

  • git.
  • gh con l'accesso fatto (gh auth login), per la CI; senza, al posto della CI compare una nota (ad esempio «esecuzioni della CI illeggibili»).
  • /bin/bash e gli strumenti dei test del progetto.

Limiti

  • Ogni test ha 5 minuti; git 15 secondi; gh 30 secondi.
  • Tiene le ultime 40 righe dell'output di un test e manda a Claude le ultime 120 del log della CI.
  • Un run: | su più righe resta alla CI.
  • node_modules lo cerca solo in cima al repository: un job che lavora in una sottocartella (working-directory) e fa npm ci viene saltato anche se lì le dipendenze ci sono.
  • Per Python non controlla l'ambiente: un job con pip install viene saltato anche se le dipendenze sono già installate. In quel caso scrivi i comandi in .claude/checks.json.
  • Solo Edit e Write segnano un repository come modificato; i file cambiati da Bash o MultiEdit no.
  • Un .claude/checks.json con una voce malformata viene ignorato per intero.
  • Il tasto c vale solo per il primo errore mostrato: repository per repository, prima i test locali e poi la CI.

Installazione

claude plugin marketplace add Wowbagger1994/claude-mods
claude plugin install checks@enrik-mods

Oppure, dal prompt di Claude Code: /plugin install checks --marketplace Wowbagger1994/claude-mods.

Sviluppo

Dalla cartella della mod:

claude plugin validate .
claude plugin test .

Oggi i test sono 34.

Source 7 files
hooks/register.tsx 280 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { CheckCommand, CheckSource, CiCheck, CiStatus, CommandRun } from '../types'
4import { news, parsePrChecks, parseRuns, runIdOf, scrub, tailOf, tally } from './ci'
5import { ciCommands, defaultCommands, isCheckWorkflow, parseOverride } from './detect'
6import type { RepoState } from './state'
7import { isFailed, statusLine } from './state'
8import type { Actions } from './view'
9import { checksView } from './view'
10
11const PLUGIN = 'checks'
12const PANE = 'checks'
13const TITLE = 'Verifiche'
14const CHECK_TIMEOUT_MS = 5 * 60_000
15const GIT_TIMEOUT_MS = 15_000
16const GH_TIMEOUT_MS = 30_000
17/** How often the CI is asked again while a check is running. */
18const POLL_MS = 30_000
19/** A push takes a moment to start its runs on GitHub. */
20const AFTER_PUSH_MS = 15_000
21/** A CI asked less than this ago is not asked again at the end of an answer. */
22const CI_FRESH_MS = 60_000
23const TAIL_LINES = 40
24const LOG_LINES = 120
25
26/** This session's repositories, by their top directory: rebuilt, never changed in place. */
27let repos: ReadonlyMap<string, RepoState> = new Map()
28/** Directories already resolved to their repository (null: none). */
29let topOf: ReadonlyMap<string, string | null> = new Map()
30/** CI failures already told, by `sha:name`. */
31let told: ReadonlySet<string> = new Set()
32/** Repositories with a CI question already scheduled. */
33let polling: ReadonlySet<string> = new Set()
34/** A run asked while one is going: done once it ends. */
35let runAgain: ReadonlySet<string> = new Set()
36
37const patch = ($: EngineInterface, repo: string, change: Partial<RepoState>) => {
38  const old = repos.get(repo)
39  if (old === undefined) return
40  repos = new Map(repos).set(repo, { ...old, ...change })
41  $.ui.invalidate('ui.render')
42  $.ui.status(statusLine([...repos.values()]) ?? undefined)
43}
44
45const isPaneShown = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
46const tell = async ($: EngineInterface, text: string) => {
47  if (!(await isPaneShown($))) $.ui.toast(text)
48}
49
50const git = async ($: EngineInterface, cwd: string, args: string[]): Promise<string | null> => {
51  const ran = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: GIT_TIMEOUT_MS }).catch(() => null)
52  return ran !== null && ran.exitCode === 0 ? ran.stdout.trim() : null
53}
54
55const dirOf = (path: string) => path.slice(0, Math.max(1, path.lastIndexOf('/')))
56const nameOf = (repo: string) => repo.split('/').filter(p => p !== '').pop() ?? repo
57
58/** The repository holding a directory, its worktree's top for a worktree; null outside one. */
59const repoOf = async ($: EngineInterface, dir: string): Promise<string | null> => {
60  if (topOf.has(dir)) return topOf.get(dir) ?? null
61  const top = await git($, dir, ['rev-parse', '--show-toplevel'])
62  topOf = new Map(topOf).set(dir, top)
63  return top
64}
65
66/** The repository's own list, else its CI's fast steps, else its kind's usual test. */
67const checksOf = async ($: EngineInterface, repo: string): Promise<{ commands: CheckCommand[]; source: CheckSource }> => {
68  const override = `${repo}/.claude/checks.json`
69  if (await $.fs.exists(override)) {
70    const commands = parseOverride(await $.fs.read(override))
71    if (commands !== null) return { commands, source: { kind: 'override', path: '.claude/checks.json' } }
72  }
73  const workflows = `${repo}/.github/workflows`
74  if (await $.fs.exists(workflows)) {
75    const hasNodeModules = await $.fs.exists(`${repo}/node_modules`)
76    for (const entry of [...(await $.fs.list(workflows))].sort((a, b) => a.name.localeCompare(b.name))) {
77      const text = await $.fs.read(`${workflows}/${entry.name}`).catch(() => '')
78      const commands = isCheckWorkflow(entry.name, text) ? ciCommands(text, hasNodeModules) : []
79      if (commands.length > 0) return { commands, source: { kind: 'ci', path: `.github/workflows/${entry.name}` } }
80    }
81  }
82  const files = (await $.fs.list(repo)).map(e => e.name)
83  const pkg = files.includes('package.json') ? await $.fs.read(`${repo}/package.json`).catch(() => null) : null
84  const found = defaultCommands(files, pkg)
85  return found === null ? { commands: [], source: { kind: 'none' } } : { commands: found.commands, source: { kind: 'default', what: found.what } }
86}
87
88const ensureRepo = async ($: EngineInterface, repo: string): Promise<RepoState> => {
89  const known = repos.get(repo)
90  if (known !== undefined) return known
91  const [{ commands, source }, branch] = await Promise.all([checksOf($, repo), git($, repo, ['branch', '--show-current'])])
92  const state: RepoState = {
93    repo, name: nameOf(repo), branch: branch === '' ? null : branch, commands, source,
94    local: null, running: null, isDirty: false, ci: null, ciNote: null, isFetching: false,
95  }
96  // Two callers may have raced here: the first one in stays.
97  if (!repos.has(repo)) repos = new Map(repos).set(repo, state)
98  $.ui.invalidate('ui.render')
99  return repos.get(repo)!
100}
101
102const runOne = async ($: EngineInterface, repo: string, c: CheckCommand): Promise<CommandRun> => {
103  patch($, repo, { running: c.label })
104  const start = await $.clock.now()
105  const ran = await $.process
106    .run(['/bin/bash', '-c', c.command], { cwd: repo, timeoutMs: CHECK_TIMEOUT_MS })
107    .catch((cause: unknown) => ({ exitCode: -1, stdout: '', stderr: `non eseguito: ${String(cause)}` }))
108  return { label: c.label, command: c.command, exitCode: ran.exitCode, durationMs: (await $.clock.now()) - start, tail: tailOf(`${ran.stdout}\n${ran.stderr}`, TAIL_LINES) }
109}
110
111/** Runs the repository's fast checks one after another; told when they fail, or pass again. */
112const runLocal = async ($: EngineInterface, repo: string) => {
113  const r = repos.get(repo)
114  if (r === undefined || r.commands.length === 0) return
115  if (r.running !== null) {
116    runAgain = new Set(runAgain).add(repo)
117    return
118  }
119  const wasFailing = (r.local?.runs ?? []).some(x => isFailed(x.exitCode))
120  patch($, repo, { isDirty: false })
121  const runs: CommandRun[] = []
122  for (const c of r.commands) runs.push(await runOne($, repo, c))
123  patch($, repo, { running: null, local: { at: await $.clock.now(), runs } })
124  const failed = runs.filter(x => isFailed(x.exitCode))
125  if (failed.length > 0) await tell($, `✗ ${r.name}: ${failed.map(x => x.label).join(', ')} — /checks`)
126  else if (wasFailing) await tell($, `✓ ${r.name}: i test sono di nuovo verdi`)
127  if (runAgain.has(repo)) {
128    runAgain = new Set([...runAgain].filter(x => x !== repo))
129    await runLocal($, repo)
130  }
131}
132
133/** The PR's checks, or with no PR the runs of the last commit. */
134const ciOf = async ($: EngineInterface, repo: string): Promise<CiStatus | string> => {
135  const sha = await git($, repo, ['rev-parse', 'HEAD'])
136  if (sha === null) return 'nessun commit'
137  const gh = (args: string[]) => $.process.run(['gh', ...args], { cwd: repo, timeoutMs: GH_TIMEOUT_MS })
138  const at = await $.clock.now()
139  const view = await gh(['pr', 'view', '--json', 'number,url,title'])
140  if (view.exitCode === 0) {
141    const pr = JSON.parse(view.stdout) as { number: number; url: string; title: string }
142    // gh exits 8 while checks are pending: the JSON is there all the same.
143    const checks = parsePrChecks((await gh(['pr', 'checks', String(pr.number), '--json', 'name,bucket,link,workflow'])).stdout)
144    return checks === null ? 'checks della PR illeggibili' : { sha, pr: { number: pr.number, url: pr.url, title: pr.title }, checks, at }
145  }
146  const runs = parseRuns((await gh(['run', 'list', '--commit', sha, '--json', 'name,status,conclusion,url,workflowName'])).stdout)
147  if (runs === null) return 'esecuzioni della CI illeggibili'
148  return runs.length === 0 ? `nessuna esecuzione della CI per ${sha.slice(0, 7)} (non ancora pushato?)` : { sha, pr: null, checks: runs, at }
149}
150
151const schedulePoll = ($: EngineInterface, repo: string, ms: number) => {
152  if (polling.has(repo)) return
153  polling = new Set(polling).add(repo)
154  $.clock.after(ms, () => {
155    polling = new Set([...polling].filter(x => x !== repo))
156    void fetchCi($, repo)
157  })
158}
159
160const fetchCi = async ($: EngineInterface, repo: string) => {
161  const before = repos.get(repo)
162  if (before === undefined || before.isFetching) return
163  patch($, repo, { isFetching: true })
164  try {
165    const found = await ciOf($, repo)
166    if (typeof found === 'string') {
167      patch($, repo, { ci: null, ciNote: found })
168      return
169    }
170    const sameCommit = before.ci?.sha === found.sha ? before.ci.checks : null
171    const { failed, isDone } = news(sameCommit, found.checks, told, found.sha)
172    told = new Set([...told, ...failed.map(c => `${found.sha}:${c.name}`)])
173    patch($, repo, { ci: found, ciNote: null })
174    if (failed.length > 0) await tell($, `✗ CI di ${before.name}: ${failed.map(c => c.name).join(', ')} — /checks, poi c per chiedere a Claude`)
175    // Over with a failure already told: nothing to add; over and all passed: said once.
176    else if (isDone && sameCommit !== null && tally(found.checks).failed === 0) await tell($, `✓ CI di ${before.name}: tutto verde`)
177    if (found.checks.some(c => c.bucket === 'pending')) schedulePoll($, repo, POLL_MS)
178  } catch (cause) {
179    patch($, repo, { ciNote: `CI non letta: ${cause instanceof Error ? cause.message : String(cause)}` })
180  } finally {
181    patch($, repo, { isFetching: false })
182  }
183}
184
185const ask = async ($: EngineInterface, text: string) => {
186  await $.prompt.submit({ text: scrub(text) })
187  $.ui.toast('Mandato a Claude')
188}
189
190const fence = (lines: readonly string[]) => ['```', ...lines, '```'].join('\n')
191const NO_GIT = 'Guarda la causa e proponi la correzione. Non fare commit né push.'
192
193const actionsFor = ($: EngineInterface): Actions => ({
194  run: repo => runLocal($, repo),
195  refreshCi: () => Promise.all([...repos.keys()].map(repo => fetchCi($, repo))),
196  askLocal: (repo, run) =>
197    ask($, `Nel repo ${repo} il controllo veloce «${run.label}» fallisce (uscita ${run.exitCode}).\nComando: ${run.command}\nUltime righe:\n${fence(run.tail)}\n${NO_GIT}`),
198  askCi: async (repo, check) => {
199    const id = runIdOf(check.link)
200    const log = id === null ? null : await $.process.run(['gh', 'run', 'view', id, '--log-failed'], { cwd: repo, timeoutMs: GH_TIMEOUT_MS }).catch(() => null)
201    const lines = log !== null && log.exitCode === 0 ? tailOf(log.stdout, LOG_LINES) : []
202    const branch = repos.get(repo)?.branch ?? '?'
203    const body = lines.length > 0 ? `Parte finale del log:\n${fence(lines)}` : `Log non disponibile: guardalo con gh run view ${id ?? ''} --log-failed.`
204    await ask($, `Nella CI di ${repo} (ramo ${branch}) il controllo «${check.name}» è fallito: ${check.link}\n${body}\n${NO_GIT}`)
205  },
206})
207
208/** A file Claude wrote: its repository joins the pane, to be checked at the end of the answer. */
209const afterEdit = async ($: EngineInterface, path: unknown) => {
210  if (typeof path !== 'string' || !path.startsWith('/')) return
211  const repo = await repoOf($, dirOf(path))
212  if (repo === null) return
213  await ensureRepo($, repo)
214  patch($, repo, { isDirty: true })
215}
216
217const fetchStale = async ($: EngineInterface) => {
218  const now = await $.clock.now()
219  await Promise.all([...repos.values()].filter(r => r.ci === null || now - r.ci.at > CI_FRESH_MS).map(r => fetchCi($, r.repo)))
220}
221
222const log = ($: EngineInterface, what: string) => (error: unknown) => $.ui.log(`${PLUGIN}: ${what}: ${String(error)}`, { to: 'debug' })
223
224export const register: Register = on => {
225  on('session.start', async ($, e, next) => {
226    await $.command.register({ name: 'checks', description: 'Verifiche: test veloci e CI dei repo su cui lavora Claude' })
227    const done = await next(e)
228    $.clock.after(0, async () => {
229      const repo = await repoOf($, await $.session.root())
230      if (repo !== null) await ensureRepo($, repo).then(() => fetchCi($, repo))
231    })
232    return done
233  })
234
235  on('command.run', { command: 'checks' }, async $ => {
236    await $.ui.open({ id: PANE, title: TITLE, focus: true }).catch(() => $.ui.open({ id: PANE, title: TITLE }))
237    $.clock.after(0, async () => {
238      const root = await repoOf($, await $.session.root())
239      if (root !== null) await ensureRepo($, root)
240      await fetchStale($)
241    })
242    return { text: 'Verifiche aperte: e esegue i test veloci, g aggiorna la CI, c chiede a Claude del primo errore.' }
243  })
244
245  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
246    const done = await next(e)
247    if (done.deny === undefined) await afterEdit($, e.file_path).catch(log($, 'repo non letto'))
248    return done
249  }).catch(($, e, next) => next(e))
250
251  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
252    const done = await next(e)
253    if (done.deny === undefined) await afterEdit($, e.file_path).catch(log($, 'repo non letto'))
254    return done
255  }).catch(($, e, next) => next(e))
256
257  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
258    const done = await next(e)
259    // A push starts the CI: ask again once GitHub has the runs.
260    if (done.deny === undefined && /\bgit\b[^|;&]*\bpush\b/.test(e.command)) for (const repo of repos.keys()) schedulePoll($, repo, AFTER_PUSH_MS)
261    return done
262  }).catch(($, e, next) => next(e))
263
264  on('turn.complete', async ($, e, next) => {
265    const done = await next(e)
266    if (e.agentId === undefined) {
267      // The answer ends now; the checks follow.
268      $.clock.after(0, async () => {
269        for (const r of [...repos.values()].filter(x => x.isDirty)) await runLocal($, r.repo).catch(log($, 'test non eseguiti'))
270        await fetchStale($).catch(log($, 'CI non letta'))
271      })
272    }
273    return done
274  }).catch(($, e, next) => next(e))
275
276  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) =>
277    checksView($.ui.resolve(e), { repos: [...repos.values()], now: await $.clock.now() }, actionsFor($)),
278  )
279}
280
hooks/ci.ts 81 lines
1import type { Bucket, CiCheck } from '../types'
2
3const BUCKETS = new Set<Bucket>(['pass', 'fail', 'pending', 'skipping', 'cancel'])
4const RUN_ID = /\/actions\/runs\/(\d+)/
5
6const parseArray = (text: string): Record<string, unknown>[] | null => {
7  try {
8    const raw = JSON.parse(text) as unknown
9    return Array.isArray(raw) ? raw.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null) : null
10  } catch {
11    return null
12  }
13}
14const str = (v: unknown) => (typeof v === 'string' ? v : '')
15
16/** `gh pr checks --json name,bucket,link,workflow`. */
17export const parsePrChecks = (text: string): CiCheck[] | null =>
18  parseArray(text)?.map(c => ({
19    name: str(c.name),
20    workflow: str(c.workflow),
21    bucket: BUCKETS.has(c.bucket as Bucket) ? (c.bucket as Bucket) : 'pending',
22    link: str(c.link),
23  })) ?? null
24
25/** A workflow run's status and conclusion as a check's bucket. */
26export const bucketOf = (status: string, conclusion: string): Bucket => {
27  if (status !== 'completed') return 'pending'
28  if (conclusion === 'success') return 'pass'
29  if (conclusion === 'skipped' || conclusion === 'neutral') return 'skipping'
30  if (conclusion === 'cancelled') return 'cancel'
31  return 'fail'
32}
33
34/** `gh run list --commit <sha> --json name,status,conclusion,url,workflowName`: the runs of a commit with no PR. */
35export const parseRuns = (text: string): CiCheck[] | null =>
36  parseArray(text)?.map(r => ({
37    name: str(r.name),
38    workflow: str(r.workflowName),
39    bucket: bucketOf(str(r.status), str(r.conclusion)),
40    link: str(r.url),
41  })) ?? null
42
43export type Tally = { total: number; passed: number; failed: number; pending: number }
44
45export const tally = (checks: readonly CiCheck[]): Tally => ({
46  total: checks.filter(c => c.bucket !== 'skipping').length,
47  passed: checks.filter(c => c.bucket === 'pass').length,
48  failed: checks.filter(c => c.bucket === 'fail' || c.bucket === 'cancel').length,
49  pending: checks.filter(c => c.bucket === 'pending').length,
50})
51
52export const runIdOf = (link: string): string | null => RUN_ID.exec(link)?.[1] ?? null
53
54/** What to tell when new checks arrive: the failures not told yet, and whether the whole lot just ended. */
55export const news = (
56  before: readonly CiCheck[] | null,
57  after: readonly CiCheck[],
58  told: ReadonlySet<string>,
59  sha: string,
60): { failed: CiCheck[]; isDone: boolean } => {
61  const failed = after.filter(c => (c.bucket === 'fail' || c.bucket === 'cancel') && !told.has(`${sha}:${c.name}`))
62  const wasPending = before === null || before.some(c => c.bucket === 'pending')
63  const isDone = after.length > 0 && wasPending && !after.some(c => c.bucket === 'pending')
64  return { failed, isDone }
65}
66
67const SECRET_PREFIX = /\b(?:ghp_|gho_|ghs_|github_pat_|ya29\.|1\/\/|ntok_|nins_|ngat_|sk-|xox[bpas]-|AKIA)[\w\-./+=]{8,}/g
68/** A run of 32 or more with letters and digits mixed: a token, not a word or a path (`/` ends it). */
69const RANDOM_RUN = /(?=[A-Za-z0-9_\-+=]*\d)(?=[A-Za-z0-9_\-+=]*[A-Za-z])[A-Za-z0-9_\-+=]{32,}/g
70
71/** A log as it may leave the machine: known token shapes and long random runs masked. */
72export const scrub = (text: string): string => text.replace(SECRET_PREFIX, '[segreto oscurato]').replace(RANDOM_RUN, '[oscurato]')
73
74/** The last `n` lines with text, scrubbed. */
75export const tailOf = (text: string, n: number): string[] =>
76  scrub(text)
77    .split('\n')
78    .map(l => l.replace(/\s+$/, ''))
79    .filter(l => l !== '')
80    .slice(-n)
81
hooks/detect.ts 142 lines
1import type { CheckCommand, CheckSource } from '../types'
2
3/** Workflows that ship things, not check them: never run locally. */
4const SHIPPING = /deploy|release|publish/i
5/** What a fast local check starts with. */
6const RUNNERS = new Set(['python3', 'python', 'pytest', 'go', 'npm', 'npx', 'bun', 'node', 'cargo', 'make', 'ruff', 'mypy'])
7/** Words that mean a step needs more than the checkout: services, installs, the network, a deploy. */
8const HEAVY = /\b(?:docker|compose|install|npm\s+ci|deploy|ssh|scp|curl|wget|sudo|apt|brew|push|publish|login|secrets?|audit|build|smoke)\b/i
9/** A step allowed to fail is no check. */
10const ALLOWED_TO_FAIL = /\|\|\s*true\s*$/
11/**
12 * Commands that put a job's dependencies in place, which its other steps count on.
13 * A laptop may lack what Python, the system or another language installs: such a
14 * job is left to the CI whole.
15 */
16const INSTALLS_DEPENDENCIES =
17  /\b(?:pip3?\s+install|uv\s+(?:pip\s+install|sync)|poetry\s+install|pipenv\s+(?:install|sync)|conda\s+(?:install|env\s+(?:create|update))|bundle\s+install|composer\s+install|apt(?:-get)?\s+install|brew\s+install)\b/
18/** JavaScript's installs land in `node_modules`: with it in the repository, they are already here. */
19const INSTALLS_NODE_MODULES = /\b(?:npm\s+(?:ci|install|i)|yarn\s+install|pnpm\s+(?:install|i)|bun\s+install)\b/
20
21const STEP_START = /^(\s*)-\s+/
22const NAME = /^\s*(?:-\s+)?name:\s*(.+?)\s*$/
23const RUN = /^\s*(?:-\s+)?run:\s*(.+?)\s*$/
24const JOBS = /^jobs:\s*(?:#.*)?$/
25const COMMENT = /^\s*#/
26const indentOf = (line: string) => line.length - line.trimStart().length
27
28const unquote = (text: string) => text.replace(/^(['"])(.*)\1$/, '$2')
29
30/** A step's command that a laptop runs in seconds: a known runner, nothing heavy. */
31export const isFastCommand = (command: string): boolean => {
32  const first = command.trim().split(/\s+/)[0] ?? ''
33  return RUNNERS.has(first) && !HEAVY.test(command) && !ALLOWED_TO_FAIL.test(command)
34}
35
36/** The Mac has no bare `python`: the CI's `python` is its `python3` here. */
37export const forThisMac = (command: string): string => command.replace(/^python(\s)/, 'python3$1')
38
39/** The lines of each job under `jobs:`, blank lines and comments left out. */
40export const jobsOf = (workflow: string): string[][] => {
41  const jobs: string[][] = []
42  let jobIndent: number | null = null
43  let isInJobs = false
44  for (const line of workflow.split('\n')) {
45    if (line.trim() === '' || COMMENT.test(line)) continue
46    if (JOBS.test(line)) {
47      isInJobs = true
48      continue
49    }
50    const indent = indentOf(line)
51    if (indent === 0) isInJobs = false
52    if (!isInJobs) continue
53    jobIndent ??= indent
54    if (indent === jobIndent) jobs.push([line])
55    else jobs[jobs.length - 1]?.push(line)
56  }
57  return jobs
58}
59
60/**
61 * A job that installs what its steps need and may be missing here: `pip install`
62 * and the like always; `npm ci` and the like only without `node_modules`.
63 */
64export const isInstallingJob = (job: readonly string[], hasNodeModules: boolean): boolean =>
65  job.some(line => INSTALLS_DEPENDENCIES.test(line) || (!hasNodeModules && INSTALLS_NODE_MODULES.test(line)))
66
67/** A job's fast steps: those whose `run:` is one line a laptop runs as is. */
68const fastSteps = (job: readonly string[]): CheckCommand[] => {
69  const found: CheckCommand[] = []
70  let name: string | null = null
71  for (const line of job) {
72    if (STEP_START.test(line)) name = null
73    const named = NAME.exec(line)
74    if (named !== null) name = unquote(named[1]!)
75    const run = RUN.exec(line)
76    if (run === null) continue
77    const command = unquote(run[1]!)
78    if (isFastCommand(command)) found.push({ label: name ?? command, command: forThisMac(command) })
79  }
80  return found
81}
82
83/**
84 * The fast steps of a workflow, job by job. A many-line `run: |` (a script, a
85 * service) is left to the CI: `|` is no runner. So is every step of a job that
86 * installs dependencies this repository lacks: the step would fail for that.
87 */
88export const ciCommands = (workflow: string, hasNodeModules: boolean): CheckCommand[] =>
89  jobsOf(workflow)
90    .filter(job => !isInstallingJob(job, hasNodeModules))
91    .flatMap(fastSteps)
92
93/** Workflow files worth reading for checks, by name. */
94export const isCheckWorkflow = (fileName: string, text: string): boolean =>
95  /\.ya?ml$/.test(fileName) && !SHIPPING.test(fileName) && !SHIPPING.test(/^name:\s*(.+)$/m.exec(text)?.[1] ?? '')
96
97/** `.claude/checks.json`: `{ "checks": [{ "label": "…", "command": "…" }] }`; null when malformed. */
98export const parseOverride = (text: string): CheckCommand[] | null => {
99  try {
100    const raw = JSON.parse(text) as { checks?: unknown }
101    if (!Array.isArray(raw.checks)) return null
102    const checks = raw.checks.filter(
103      (c): c is CheckCommand => typeof c === 'object' && c !== null && typeof (c as CheckCommand).label === 'string' && typeof (c as CheckCommand).command === 'string',
104    )
105    return checks.length === raw.checks.length ? checks : null
106  } catch {
107    return null
108  }
109}
110
111/** Without a CI to copy: the usual test command of the project's kind. */
112export const defaultCommands = (files: readonly string[], packageJson: string | null): { commands: CheckCommand[]; what: string } | null => {
113  if (files.includes('go.mod')) return { commands: [{ label: 'go test', command: 'go test ./...' }], what: 'go.mod' }
114  if (packageJson !== null) {
115    try {
116      const scripts = (JSON.parse(packageJson) as { scripts?: Record<string, unknown> }).scripts ?? {}
117      if (typeof scripts.test === 'string' && !/no test specified/.test(scripts.test)) {
118        const runner = files.includes('bun.lockb') || files.includes('bun.lock') ? 'bun run test' : 'npm test --silent'
119        return { commands: [{ label: 'test', command: runner }], what: 'package.json' }
120      }
121    } catch {
122      return null
123    }
124  }
125  if (files.includes('pytest.ini') || files.includes('conftest.py')) return { commands: [{ label: 'pytest', command: 'python3 -m pytest -q' }], what: 'pytest' }
126  if (files.includes('Cargo.toml')) return { commands: [{ label: 'cargo test', command: 'cargo test --quiet' }], what: 'Cargo.toml' }
127  return null
128}
129
130export const sourceLabel = (s: CheckSource): string => {
131  switch (s.kind) {
132    case 'override':
133      return `da ${s.path}`
134    case 'ci':
135      return `i passi veloci di ${s.path}`
136    case 'default':
137      return `dal ${s.what}`
138    case 'none':
139      return 'nessun test trovato'
140  }
141}
142
hooks/state.ts 41 lines
1import type { CheckCommand, CheckSource, CiStatus, LocalRun } from '../types'
2
3/** One repository the session worked in, and what is known of its checks. */
4export type RepoState = {
5  repo: string
6  name: string
7  branch: string | null
8  commands: CheckCommand[]
9  source: CheckSource
10  local: LocalRun | null
11  /** The check running now; null when none. */
12  running: string | null
13  /** Claude edited it since its last run. */
14  isDirty: boolean
15  ci: CiStatus | null
16  /** Why there is no CI to show: no gh, no run for the commit, an error. */
17  ciNote: string | null
18  isFetching: boolean
19}
20
21export const isFailed = (exitCode: number) => exitCode !== 0
22
23/** `✓ test 4/4 · CI ✓ 2/2`, `✗ test 1/4 · CI ⏳ 1/3`; null with nothing to tell. */
24export const statusLine = (repos: readonly RepoState[]): string | null => {
25  const runs = repos.flatMap(r => r.local?.runs ?? [])
26  const isRunning = repos.some(r => r.running !== null)
27  const checks = repos.flatMap(r => r.ci?.checks ?? []).filter(c => c.bucket !== 'skipping')
28  const parts: string[] = []
29  if (isRunning) parts.push('⏳ test')
30  else if (runs.length > 0) {
31    const passed = runs.filter(r => !isFailed(r.exitCode)).length
32    parts.push(`${passed === runs.length ? '✓' : '✗'} test ${passed}/${runs.length}`)
33  }
34  if (checks.length > 0) {
35    const passed = checks.filter(c => c.bucket === 'pass').length
36    const mark = checks.some(c => c.bucket === 'fail' || c.bucket === 'cancel') ? '✗' : checks.some(c => c.bucket === 'pending') ? '⏳' : '✓'
37    parts.push(`CI ${mark} ${passed}/${checks.length}`)
38  }
39  return parts.length === 0 ? null : `${parts.join(' · ')} · /checks`
40}
41
hooks/view.tsx 293 lines
1import type { Color, ElementTable, RenderChildren } from 'claude-code'
2
3import type { CiCheck, CommandRun } from '../types'
4import type { Tally } from './ci'
5import { tally } from './ci'
6import { sourceLabel } from './detect'
7import type { RepoState } from './state'
8import { isFailed } from './state'
9import type { Verdict } from './verdict'
10import { failuresOf, isCheckFailed, meterCells, overallOf, verdictOf } from './verdict'
11
12const TAIL_SHOWN = 8
13const SECOND_MS = 1000
14const MINUTE_MS = 60_000
15const METER_WIDTH = 12
16const HALF = 0.5
17
18export type Actions = {
19  run: (repo: string) => unknown
20  refreshCi: () => unknown
21  askLocal: (repo: string, run: CommandRun) => unknown
22  askCi: (repo: string, check: CiCheck) => unknown
23}
24
25export type ChecksState = { repos: readonly RepoState[]; now: number }
26
27type Remark = { text: string; color?: Color }
28
29const MARK = { pass: '✓', fail: '✗', cancel: '✗', pending: '◌', skipping: '–' } as const
30const COLOR = { pass: 'success', fail: 'error', cancel: 'error', pending: 'warning', skipping: 'inactive' } as const
31const VERDICT_COLOR: Record<Verdict, Color | undefined> = { fail: 'error', running: 'warning', pass: 'success', idle: undefined }
32
33export const ago = (at: number, now: number): string => {
34  const ms = Math.max(0, now - at)
35  if (ms < MINUTE_MS) return 'adesso'
36  const minutes = Math.round(ms / MINUTE_MS)
37  return minutes < 60 ? `${minutes} min fa` : `${Math.round(minutes / 60)} h fa`
38}
39
40const seconds = (ms: number) => `${(ms / SECOND_MS).toFixed(ms < 10 * SECOND_MS ? 1 : 0)} s`
41
42/** The words on a verdict's badge; null when there is nothing to say. */
43export const badgeText = (verdict: Verdict, failures: number): string | null => {
44  if (verdict === 'fail') return `✗ ${failures} da sistemare`
45  if (verdict === 'running') return '◌ in corso'
46  if (verdict === 'pass') return '✓ tutto verde'
47  return null
48}
49
50/** The verdict as a label: dark text on its colour. */
51const badge = (el: ElementTable, verdict: Verdict, failures: number) => {
52  const text = badgeText(verdict, failures)
53  const color = VERDICT_COLOR[verdict]
54  if (text === null || color === undefined) return null
55  return (
56    <el.Text bold color="inverseText" backgroundColor={color}>
57      {` ${text} `}
58    </el.Text>
59  )
60}
61
62/** `████████░░░░`: green passed, red failed, yellow pending, the rest dim. */
63const meter = (el: ElementTable, t: Tally) => {
64  const c = meterCells(t, METER_WIDTH)
65  return (
66    <el.Text>
67      <el.Text color="success">{'█'.repeat(c.pass)}</el.Text>
68      <el.Text color="error">{'█'.repeat(c.fail)}</el.Text>
69      <el.Text color="warning">{'█'.repeat(c.pending)}</el.Text>
70      <el.Text dimColor>{'░'.repeat(c.empty)}</el.Text>
71    </el.Text>
72  )
73}
74
75/** A section's title on the left, what it offers on the right. */
76const sectionTitle = (el: ElementTable, title: string, detail: string, right: RenderChildren) => (
77  <el.Box flexDirection="row" justifyContent="space-between" columnGap={2}>
78    <el.Text wrap="truncate-end">
79      <el.Text bold color="claude">{`▍${title}`}</el.Text>
80      <el.Text dimColor>{detail === '' ? '' : `  ${detail}`}</el.Text>
81    </el.Text>
82    {right}
83  </el.Box>
84)
85
86/** The meter, the count in its colour, then the remarks. */
87const summaryRow = (el: ElementTable, t: Tally, count: { text: string; color: Color } | null, remarks: readonly Remark[]) => (
88  <el.Box flexDirection="row" columnGap={1} paddingLeft={1}>
89    {meter(el, t)}
90    {count !== null && <el.Text bold color={count.color}>{count.text}</el.Text>}
91    {remarks.map((r, i) => (
92      <el.Text key={r.text} color={r.color} dimColor={r.color === undefined}>{`${count === null && i === 0 ? '' : '· '}${r.text}`}</el.Text>
93    ))}
94  </el.Box>
95)
96
97/** A failure's last lines, behind a red bar. */
98const tail = (el: ElementTable, key: string, lines: readonly string[]) => (
99  <el.Box flexDirection="column" paddingLeft={3}>
100    {lines.slice(-TAIL_SHOWN).map((line, i) => (
101      <el.Box key={`${key}:${i}`} flexDirection="row" columnGap={1}>
102        <el.Text color="error">│</el.Text>
103        <el.Text dimColor wrap="truncate-end">{line}</el.Text>
104      </el.Box>
105    ))}
106  </el.Box>
107)
108
109/** The first failure's button stands out: it is the one `c` presses. */
110const askButton = (el: ElementTable, key: string, isFirst: boolean, onPress: () => unknown) =>
111  isFirst ? (
112    <el.Button key={key} label="Chiedi a Claude" hotkey="c" variant="primary" onPress={onPress} />
113  ) : (
114    <el.Button key={key} label="Chiedi a Claude" plain onPress={onPress} />
115  )
116
117/** One local check; dim while a new run replaces it. */
118const localRow = (el: ElementTable, r: RepoState, x: CommandRun, isStale: boolean, isFirst: boolean, act: Actions) => {
119  const isFail = isFailed(x.exitCode)
120  return (
121    <el.Box key={`cmd:${r.repo}:${x.label}`} flexDirection="column">
122      <el.Box flexDirection="row" justifyContent="space-between" columnGap={2} paddingLeft={1}>
123        <el.Box flexDirection="row" columnGap={1} flexShrink={1}>
124          <el.Text color={isFail ? 'error' : 'success'} dimColor={isStale}>{isFail ? '✗' : '✓'}</el.Text>
125          <el.Text bold={isFail} dimColor={isStale} wrap="truncate-end">{x.label}</el.Text>
126          <el.Text dimColor>{seconds(x.durationMs)}</el.Text>
127        </el.Box>
128        {isFail && askButton(el, `ask-local:${r.repo}:${x.label}`, isFirst, () => act.askLocal(r.repo, x))}
129      </el.Box>
130      {isFail && !isStale && tail(el, `tail:${r.repo}:${x.label}`, x.tail)}
131    </el.Box>
132  )
133}
134
135/** The run going on, or the last one's age. */
136export const localRemarks = (r: RepoState, now: number): Remark[] => {
137  if (r.running !== null) {
138    const at = r.commands.findIndex(c => c.label === r.running)
139    return [{ text: `in corso: ${r.running}${at < 0 ? '' : ` (${at + 1} di ${r.commands.length})`}`, color: 'warning' }]
140  }
141  if (r.local === null) return []
142  return [{ text: ago(r.local.at, now) }, ...(r.isDirty ? [{ text: 'modifiche dopo', color: 'warning' as const }] : [])]
143}
144
145/** The last run's outcome; during a run, how far it got, in yellow: the checks done and half the one running. */
146export const localTally = (r: RepoState): Tally => {
147  if (r.running !== null) {
148    const at = r.commands.findIndex(c => c.label === r.running)
149    return { total: r.commands.length, passed: 0, failed: 0, pending: Math.max(0, at) + HALF }
150  }
151  const runs = r.local?.runs ?? []
152  const passed = runs.filter(x => !isFailed(x.exitCode)).length
153  return { total: runs.length, passed, failed: runs.length - passed, pending: 0 }
154}
155
156const localSection = (el: ElementTable, r: RepoState, now: number, act: Actions, firstFailure: string | null, isFirstRepo: boolean) => {
157  const runs = r.local?.runs ?? []
158  const passed = runs.filter(x => !isFailed(x.exitCode)).length
159  const isStale = r.running !== null
160  const isGreen = passed === runs.length
161  const count = isStale ? null : { text: `${isGreen ? '✓' : '✗'} ${passed}/${runs.length}`, color: isGreen ? 'success' : 'error' }
162  const runButton = r.commands.length > 0 && r.running === null && (
163    <el.Button key={`run:${r.repo}`} label="▶ Esegui" hotkey={isFirstRepo ? 'e' : undefined} plain dimColor onPress={() => act.run(r.repo)} />
164  )
165  return (
166    <el.Box key={`local:${r.repo}`} flexDirection="column">
167      {sectionTitle(el, 'Test locali', sourceLabel(r.source), runButton)}
168      {r.commands.length === 0 && r.local === null && (
169        <el.Text dimColor wrap="wrap">
170          Nessun test veloce trovato. Scrivi i comandi in .claude/checks.json del repo: {'{"checks": [{"label": "unit", "command": "…"}]}'}
171        </el.Text>
172      )}
173      {r.commands.length > 0 && r.local === null && r.running === null && (
174        <el.Text dimColor wrap="wrap">{` Non ancora eseguiti: ${r.commands.map(c => c.label).join(', ')}.`}</el.Text>
175      )}
176      {(r.local !== null || isStale) && summaryRow(el, localTally(r), count, localRemarks(r, now))}
177      {runs.map(x => localRow(el, r, x, isStale, firstFailure === `local:${r.repo}:${x.label}`, act))}
178    </el.Box>
179  )
180}
181
182/** The workflow before the name, only when the checks come from more than one. */
183const checkName = (c: CiCheck, isMixed: boolean) => (isMixed && c.workflow !== '' && c.workflow !== c.name ? `${c.workflow} · ${c.name}` : c.name)
184
185export const isMixedWorkflows = (checks: readonly CiCheck[]) => new Set(checks.map(c => c.workflow).filter(w => w !== '')).size > 1
186
187/** A failed or pending check, on a row of its own. */
188const ciRow = (el: ElementTable, r: RepoState, c: CiCheck, i: number, isMixed: boolean, isFirst: boolean, act: Actions) => (
189  <el.Box key={`check:${r.repo}:${i}`} flexDirection="row" justifyContent="space-between" columnGap={2} paddingLeft={1}>
190    <el.Box flexDirection="row" columnGap={1} flexShrink={1}>
191      <el.Text color={COLOR[c.bucket]}>{MARK[c.bucket]}</el.Text>
192      <el.Text bold={isCheckFailed(c)} wrap="truncate-end">{checkName(c, isMixed)}</el.Text>
193      {c.link !== '' && <el.Link href={c.link} label="apri" />}
194    </el.Box>
195    {isCheckFailed(c) && askButton(el, `ask-ci:${r.repo}:${i}`, isFirst, () => act.askCi(r.repo, c))}
196  </el.Box>
197)
198
199/** Passed and skipped checks: short, side by side, wrapping. */
200const ciChips = (el: ElementTable, r: RepoState, checks: readonly { c: CiCheck; i: number }[], isMixed: boolean) =>
201  checks.length > 0 && (
202    <el.Box flexDirection="row" flexWrap="wrap" columnGap={2} paddingLeft={1}>
203      {checks.map(({ c, i }) => (
204        <el.Text key={`chip:${r.repo}:${i}`} color={COLOR[c.bucket]} dimColor={c.bucket === 'skipping'}>
205          {`${MARK[c.bucket]} ${checkName(c, isMixed)}`}
206        </el.Text>
207      ))}
208    </el.Box>
209  )
210
211const isSettled = (c: CiCheck) => c.bucket === 'pass' || c.bucket === 'skipping'
212
213const ciSection = (el: ElementTable, r: RepoState, act: Actions, firstFailure: string | null) => {
214  const ci = r.ci
215  const where = ci === null ? '' : ci.pr !== null ? `PR #${ci.pr.number} ${ci.pr.title}` : `commit ${ci.sha.slice(0, 7)}`
216  const indexed = (ci?.checks ?? []).map((c, i) => ({ c, i }))
217  const t = ci === null ? null : tally(ci.checks)
218  const isMixed = isMixedWorkflows(ci?.checks ?? [])
219  const fetching = r.isFetching && <el.Text dimColor italic>aggiorno…</el.Text>
220  return (
221    <el.Box key={`ci:${r.repo}`} flexDirection="column">
222      {sectionTitle(el, 'CI', where, fetching)}
223      {r.ciNote !== null && <el.Text dimColor wrap="wrap">{` ${r.ciNote}`}</el.Text>}
224      {t !== null &&
225        t.total > 0 &&
226        summaryRow(
227          el,
228          t,
229          {
230            text: `${t.failed > 0 ? '✗' : t.pending > 0 ? '◌' : '✓'} ${t.passed}/${t.total}`,
231            color: t.failed > 0 ? 'error' : t.pending > 0 ? 'warning' : 'success',
232          },
233          t.pending > 0 ? [{ text: `${t.pending} in corso`, color: 'warning' }] : [],
234        )}
235      {indexed.filter(({ c }) => !isSettled(c)).map(({ c, i }) => ciRow(el, r, c, i, isMixed, firstFailure === `ci:${r.repo}:${i}`, act))}
236      {ciChips(el, r, indexed.filter(({ c }) => isSettled(c)), isMixed)}
237    </el.Box>
238  )
239}
240
241/** The failure `c` asks Claude about: the first shown, a repository's local ones before its CI's. */
242export const firstFailureOf = (repos: readonly RepoState[]): string | null => {
243  for (const r of repos) {
244    const local = (r.local?.runs ?? []).find(x => isFailed(x.exitCode))
245    if (local !== undefined) return `local:${r.repo}:${local.label}`
246    const ci = (r.ci?.checks ?? []).findIndex(isCheckFailed)
247    if (ci >= 0) return `ci:${r.repo}:${ci}`
248  }
249  return null
250}
251
252/** A repository's card, its border in its verdict's colour. */
253const repoCard = (el: ElementTable, r: RepoState, s: ChecksState, act: Actions, firstFailure: string | null) => {
254  const verdict = verdictOf(r)
255  const color = VERDICT_COLOR[verdict]
256  return (
257    <el.Box key={`repo:${r.repo}`} flexDirection="column" borderStyle="round" borderColor={color} borderDimColor={color === undefined} paddingX={1} gap={1}>
258      <el.Box flexDirection="row" justifyContent="space-between" columnGap={2}>
259        <el.Text wrap="truncate-end">
260          <el.Text bold>{r.name}</el.Text>
261          <el.Text dimColor>{r.branch === null ? '' : `  ⎇ ${r.branch}`}</el.Text>
262        </el.Text>
263        {badge(el, verdict, failuresOf(r))}
264      </el.Box>
265      {localSection(el, r, s.now, act, firstFailure, r.repo === s.repos[0]?.repo)}
266      {ciSection(el, r, act, firstFailure)}
267    </el.Box>
268  )
269}
270
271/** Each repository the session touched: its fast tests here, its CI on GitHub. */
272export const checksView = (el: ElementTable, s: ChecksState, act: Actions) => {
273  const firstFailure = firstFailureOf(s.repos)
274  const overall = overallOf(s.repos)
275  return (
276    <el.Box flexDirection="column" gap={1}>
277      <el.Box flexDirection="row" justifyContent="space-between" columnGap={2}>
278        <el.Box flexDirection="row" columnGap={2}>
279          <el.Text bold color="claude">◆ Verifiche</el.Text>
280          {s.repos.length > 1 && badge(el, overall.verdict, overall.failures)}
281        </el.Box>
282        <el.Button key="refresh-ci" label="↻ aggiorna la CI" hotkey="g" plain dimColor onPress={() => act.refreshCi()} />
283      </el.Box>
284      {s.repos.length === 0 && (
285        <el.Text dimColor wrap="wrap">
286          Nessun repository ancora: appena Claude modifica un file in un repo git, qui compaiono i suoi test veloci e la sua CI.
287        </el.Text>
288      )}
289      {s.repos.map(r => repoCard(el, r, s, act, firstFailure))}
290    </el.Box>
291  )
292}
293
hooks/verdict.ts 60 lines
1import type { CiCheck } from '../types'
2import type { Tally } from './ci'
3import type { RepoState } from './state'
4import { isFailed } from './state'
5
6/** How a repository stands, worst first: red, yellow, green, nothing known yet. */
7export type Verdict = 'fail' | 'running' | 'pass' | 'idle'
8
9const RANK: Record<Verdict, number> = { fail: 0, running: 1, pass: 2, idle: 3 }
10
11export const isCheckFailed = (c: CiCheck) => c.bucket === 'fail' || c.bucket === 'cancel'
12
13/** The local runs and the CI checks that failed. */
14export const failuresOf = (r: RepoState): number =>
15  (r.local?.runs ?? []).filter(x => isFailed(x.exitCode)).length + (r.ci?.checks ?? []).filter(isCheckFailed).length
16
17export const verdictOf = (r: RepoState): Verdict => {
18  if (failuresOf(r) > 0) return 'fail'
19  const checks = r.ci?.checks ?? []
20  if (r.running !== null || checks.some(c => c.bucket === 'pending')) return 'running'
21  const hasResult = (r.local?.runs.length ?? 0) > 0 || checks.some(c => c.bucket === 'pass')
22  return hasResult ? 'pass' : 'idle'
23}
24
25/** The pane's verdict: its worst repository's, with every failure counted. */
26export const overallOf = (repos: readonly RepoState[]): { verdict: Verdict; failures: number } => ({
27  verdict: repos.map(verdictOf).reduce<Verdict>((worst, v) => (RANK[v] < RANK[worst] ? v : worst), 'idle'),
28  failures: repos.reduce((n, r) => n + failuresOf(r), 0),
29})
30
31export type MeterCells = { pass: number; fail: number; pending: number; empty: number }
32
33type Part = 'pass' | 'fail' | 'pending'
34
35/** A tally as `width` cells: each part its share, at least one cell when it is not zero. */
36export const meterCells = (t: Tally, width: number): MeterCells => {
37  if (t.total <= 0) return { pass: 0, fail: 0, pending: 0, empty: width }
38  const counts: Record<Part, number> = { pass: t.passed, fail: t.failed, pending: t.pending }
39  const parts: Part[] = ['pass', 'fail', 'pending']
40  const exact = (p: Part) => (counts[p] * width) / t.total
41  const target = Math.min(width, Math.round(((t.passed + t.failed + t.pending) * width) / t.total))
42  const start = Object.fromEntries(parts.map(p => [p, counts[p] > 0 ? Math.max(1, Math.round(exact(p))) : 0])) as Record<Part, number>
43  const sum = (c: Record<Part, number>) => c.pass + c.fail + c.pending
44  const settle = (c: Record<Part, number>): Record<Part, number> => {
45    if (sum(c) > target) {
46      // Taken from the largest part that can spare a cell.
47      const from = parts.filter(p => c[p] > 1).sort((a, b) => c[b] - c[a])[0]
48      return from === undefined ? c : settle({ ...c, [from]: c[from] - 1 })
49    }
50    if (sum(c) < target) {
51      // Given to the part furthest below its share.
52      const to = parts.filter(p => counts[p] > 0).sort((a, b) => exact(b) - c[b] - (exact(a) - c[a]))[0]
53      return to === undefined ? c : settle({ ...c, [to]: c[to] + 1 })
54    }
55    return c
56  }
57  const cells = settle(start)
58  return { ...cells, empty: width - sum(cells) }
59}
60
types/index.d.ts 33 lines
1/** A fast check to run locally: a shell line, run from the repository's top. */
2export type CheckCommand = { label: string; command: string }
3
4/** Where a repository's checks come from. */
5export type CheckSource =
6  | { kind: 'override'; path: string }
7  | { kind: 'ci'; path: string }
8  | { kind: 'default'; what: string }
9  | { kind: 'none' }
10
11export type CommandRun = {
12  label: string
13  command: string
14  exitCode: number
15  durationMs: number
16  /** The output's last lines, secrets masked. */
17  tail: string[]
18}
19
20export type LocalRun = { at: number; runs: CommandRun[] }
21
22/** As gh names a check's outcome. */
23export type Bucket = 'pass' | 'fail' | 'pending' | 'skipping' | 'cancel'
24
25export type CiCheck = { name: string; workflow: string; bucket: Bucket; link: string }
26
27export type CiStatus = {
28  sha: string
29  pr: { number: number; url: string; title: string } | null
30  checks: CiCheck[]
31  at: number
32}
33