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

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.
/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:
| Tasto | Cosa fa |
|---|---|
e | «▶ Esegui»: lancia di nuovo i test veloci del primo repository |
g | Aggiorna 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.
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:
.claude/checks.json del repository: {"checks": [{"label": "unit", "command": "…"}]};.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;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..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./bin/bash -c <comando> dalla cima del repository: sono comandi scritti nei file del repository, lanciati sul tuo computer senza chiedere.git -C <cartella> rev-parse --show-toplevel, branch --show-current e rev-parse HEAD.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.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].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.git 15 secondi; gh 30 secondi.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.pip install viene saltato anche se le dipendenze sono già installate. In quel caso scrivi i comandi in .claude/checks.json..claude/checks.json con una voce malformata viene ignorato per intero.c vale solo per il primo errore mostrato: repository per repository, prima i test locali e poi la CI.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.
Dalla cartella della mod:
claude plugin validate .
claude plugin test .
Oggi i test sono 34.
hooks/register.tsx 280 lines1import 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}
280hooks/ci.ts 81 lines1import 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)
81hooks/detect.ts 142 lines1import 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}
142hooks/state.ts 41 lines1import 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}
41hooks/view.tsx 293 lines1import 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}
293hooks/verdict.ts 60 lines1import 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}
60types/index.d.ts 33 lines1/** 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