SLOPSHOPPER

ci-watch

A band above the prompt that follows the Jenkins build of the current branch, stage by stage

newbandcommandtoastpromptprocess
★ 1v?MITupdated 2026-10-02aguiddir/claude-config/plugins/ci-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ci-watch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ci ⎿ ci-watch: CI suivie : le répertoire de la session · aucun build Jenkins trouvé ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-config

My Claude Code setup, tuned for Opus 5.5 / Sonnet 5.5.

git clone git@github.com:aguiddir/claude-config.git ~/PycharmProjects/claude-config
cd ~/PycharmProjects/claude-config
./install.sh --dry-run   # see what would change
./install.sh             # apply; safe to re-run

Needs claude, python3 and git. npm is optional (Playwright CLI). Skip parts with --skip-plugins, --skip-mcp, --skip-playwright.

Just the desktop notifications

A notification when Claude finishes, fails, asks for a permission or asks a question. In herdr it names the workspace and the agent session, and stays quiet for the pane you are looking at. Each session keeps a single notification, replaced in place, that leaves the screen after a few seconds. Linux only (D-Bus notifications, tested on GNOME); needs jq.

/plugin marketplace add aguiddir/claude-config
/plugin install notify@claude-config

Titles are in French: edit the case in plugins/notify/scripts/notify.sh to change them.

Just the replay

After a turn that edited files, a band above the prompt offers to replay them: press r in an empty prompt, or type /replay any time. The edits show one diff at a time above the prompt; n and p step, q closes, the mouse wheel scrolls a long diff. Only Edit and Write calls are recorded, not changes made through Bash. Needs Claude Code 2.1.287 or later (mods).

/plugin marketplace add aguiddir/claude-config
/plugin install replay@claude-config

Messages are in French: edit the strings in plugins/replay/hooks/register.tsx to change them.

Just the CI watch

For Vidal repos built on jenkins.vidal.net. A band above the prompt follows the Jenkins build of the current branch, or of its PR (PR-<n>) when the branch has no job of its own: a progress bar from Jenkins' estimated duration, the stage running, each stage's state, and a link to the build. When the build ends, a toast gives its result. Once the build's own SonarQube stage has run, the band shows the quality gate of the branch or PR with the conditions that failed, read 30 seconds after the end so that it is this build's analysis, and a toast says when it fails. A finished build stays on the band for 15 minutes after it ends or after /ci, whichever is later, so /ci shows the build it finds however old. When the build fails or its gate is red, f in an empty prompt (until the next prompt you send; after that, ctrl+x tab then f, or the band's button) puts the failure in the prompt box, to read, edit and send yourself: the failed stage, the console's error lines and the gate's broken conditions. Sending that prompt attaches the last 150 lines of the console, out of the box.

It reads the repo and branch of the directory Claude Code runs in and polls Jenkins every 10 seconds, anonymously, from the Vidal network. /ci 231, /ci #231 or /ci <PR URL> follows that PR's job whatever the branch, /ci <path> follows another repo, /ci alone goes back to the session's directory; each answers with the build it found. Needs gh (to find the PR) and, for the quality gate, the SonarQube MCP server connected in Claude Code. Needs Claude Code 2.1.287 or later.

/plugin marketplace add aguiddir/claude-config
/plugin install ci-watch@claude-config

The Jenkins host and job folder (team.software/github) are set at the top of plugins/ci-watch/hooks/jenkins.ts.

Just the PR comments

A band above the prompt lists the open PRs of the repo that have unresolved review threads, in two groups, so it works from main too: those to work on (yours, and the current branch's whoever wrote it) and those you review (asked or already reviewed): 💬 à traiter : #89 (9) · en relecture : #73 (10). c in an empty prompt (while new threads have come in and no prompt has been sent since; otherwise /pr-review, or /pr-review 89 for one PR) opens them in a pane, one thread at a time: the code it is about, as a diff, and its whole conversation, badged ↩ répondu when the PR's author has the last word and obsolète when it is about older code. In the pane:

KeyDoes
n / pnext / previous thread
tnext PR, when several have threads
xmark the thread, for f
amark every thread, or none once they all are
fputs the marked threads (or the one shown) in the prompt box, to read, edit and send to Claude; once sent they are badged → Claude. When the PR is not on the current branch, the prompt tells Claude to check it out first (or use a worktree if changes are in progress). On your own PR it asks for one --fixup commit per commit corrected, then a push; on anyone else's, a local fix with no commit and no push
rwrites a reply, posted to GitHub on Enter; Esc gives it up and keeps the pane, as the field takes every letter meanwhile
vresolves the thread on GitHub
oopens the thread in the browser
q / Esccloses the pane

The keys work while the pane holds the keyboard; if it opened without it, ctrl+x tab or a click gives it. Replies and resolutions go through gh with your account, with no confirmation beyond the key. GitHub is asked every minute for the list of PRs (1 point of its 5000 an hour), and a PR's threads only when it changed, after a write to it, or every ten minutes in case someone else resolved one. Needs gh, logged in, and Claude Code 2.1.287 or later.

/plugin marketplace add aguiddir/claude-config
/plugin install pr-comments@claude-config

Just the explain-diff

Reading a diff is the slowest way to understand what an agent did. /explain-diff (or "explique-moi ce que tu as changé") publishes a private web page about the session's edits, a commit range (/explain-diff A^..B), a PR (/explain-diff #42) or a module (/explain-diff src/billing): what changed in one sentence, a before/after diagram of each changed flow, the files grouped by intent, the decisions made and the options not taken, the boundaries crossed (API, schema, config, security), and the open points. It runs no check and gives no verdict: the reader judges. A change that alters no behaviour (a rename, a config value, a bump) stays in the terminal, whatever its size. The page is in French, written at about 80% of ASD-STE100. After Karpathy's post on the output formats that are fastest to understand. Needs the Artifact tool (claude.ai login).

/plugin marketplace add aguiddir/claude-config
/plugin install explain-diff@claude-config

What's inside

PathInstalled asWhat it does
claude/CLAUDE.md~/.claude/CLAUDE.md (symlink)Global instructions: autonomy, long sessions, pre-review format
claude/rules/git-commit.md~/.claude/rules/ (symlink)Commit message rules: why over what, fixup commits for review changes
claude/statusline-command.sh~/.claude/ (symlink)3-line status line: repo/git/PR, context/cost, rate limits
claude/settings.jsonmerged into ~/.claude/settings.jsonAuto mode, status line, Opus effort, theme
plugins.txtclaude plugin installMarketplaces and plugins
plugins/notify/plugin notify@claude-configDesktop notification (D-Bus) per hook event: done, error, permission, question
plugins/replay/plugin replay@claude-config/replay (or r after a turn) steps through the last turn's Edit/Write calls as diffs above the prompt (a mod)
plugins/ci-watch/plugin ci-watch@claude-configBand above the prompt following the current branch's Jenkins build stage by stage, with its Sonar quality gate (a mod)
plugins/pr-comments/plugin pr-comments@claude-configBand and pane for the PR's unresolved review threads: send some to Claude, reply, resolve (a mod)
plugins/explain-diff/plugin explain-diff@claude-config/explain-diff: a private web page to understand a change before merging it (before/after diagrams, files by intent, decisions)

Symlinked files take effect as soon as you edit them here. settings.json is merged instead of linked because Claude Code rewrites it (/config, /model); existing keys and permission rules are kept, and the previous file is saved as .bak.<date>.

The installer also sets up:

  • codebase-memory-mcp: code knowledge-graph MCP server, from the release tarball after a SHA-256 check, registered for Claude Code only.
  • jq in ~/.local/bin, from the release binary after a SHA-256 check, when it is not already installed. The notify plugin needs it.
  • Playwright agent CLI with its skills, when npm is available.

Sources

Source 3 files
hooks/register.tsx 320 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Build } from '../types'
5import { bar, errorLines, fixHeader, fixPrompt, frameColor, isFixable, jobUrl, live, minutes, prOf, repoOf, short, sonarKeyOf, sonarRan, stageMark, logContext, tail, toBuild, toGate } from './jenkins'
6import type { GateJson, RunJson, StagesJson } from './jenkins'
7
8const build = atom({ plugin: 'ci-watch', key: 'build' } as const, null)
9// The repo /ci points at; null follows the session's own directory.
10const dir = atom({ plugin: 'ci-watch', key: 'dir' } as const, null)
11// The PR /ci points at, as prOf gives it; null follows the branch.
12const pr = atom({ plugin: 'ci-watch', key: 'pr' } as const, null)
13// Bumped every second while a build runs, so the band's clock moves between
14// polls without asking Jenkins.
15const second = atom({ plugin: 'ci-watch', key: 'second' } as const, 0)
16// When /ci was last typed: it shows the build it finds, however old.
17const askedAt = atom({ plugin: 'ci-watch', key: 'askedAt' } as const, 0)
18const attachment = atom({ plugin: 'ci-watch', key: 'attachment' } as const, null)
19const armed = atom({ plugin: 'ci-watch', key: 'armed' } as const, false)
20const POLL_MS = 10_000
21// A finished build stays on the band this long after it ends or after /ci,
22// whichever is later, then the band hides.
23const SHOW_DONE_MS = 15 * 60_000
24// Sonar processes an analysis a little after the build: the gate is read
25// again on every poll this long after the end, then kept.
26const GATE_FRESH_MS = 2 * 60_000
27// Right after the end, Sonar may still hold the previous analysis's gate.
28const GATE_SETTLE_MS = 30_000
29const SONAR_GATE = 'mcp__sonarqube__get_project_quality_gate_status'
30const RUN_TREE = 'tree=number,building,result,timestamp,estimatedDuration,duration'
31
32const run = async ($: EngineInterface, cwd: string, argv: string[]) => {
33  const r = await $.process.run(argv, { cwd }).catch(() => undefined)
34  return r && r.exitCode === 0 ? r.stdout.trim() : ''
35}
36
37// `missing` is Jenkins saying the job or build is not there (404); `failed`
38// is anything else (network, 5xx, bad JSON), after which state is kept.
39type Fetched<T> = { data: T } | 'missing' | 'failed'
40
41const getJson = async <T,>($: EngineInterface, url: string): Promise<Fetched<T>> => {
42  const r = await $.http.fetch(url).catch(() => undefined)
43  if (r?.status === 404) return 'missing'
44  if (!r?.ok) return 'failed'
45  try {
46    return { data: JSON.parse(r.text) as T }
47  } catch {
48    return 'failed'
49  }
50}
51
52const tryJob = async ($: EngineInterface, repo: string, job: string) => {
53  const url = jobUrl(repo, job)
54  const got = await getJson<RunJson>($, `${url}/lastBuild/api/json?${RUN_TREE}`)
55  if (typeof got === 'string') return got
56  const sonarRef = job.startsWith('PR-') ? { pullRequest: job.slice(3) } : { branch: job }
57  return { url, runJson: got.data, label: `${repo} · ${job}`, sonarRef }
58}
59
60// The branch's own job, else the PR job Jenkins builds for it instead; `gh`
61// only runs when the branch has no job.
62const findRun = async ($: EngineInterface, cwd: string, repo: string, branch: string) => {
63  const own = await tryJob($, repo, branch)
64  if (own !== 'missing') return own
65  const number = await run($, cwd, ['gh', 'pr', 'view', '--json', 'number', '-q', '.number'])
66  return number ? tryJob($, repo, `PR-${number}`) : 'missing'
67}
68
69// Through the SonarQube MCP server, so no token lives here; null when it is
70// not connected or the project has no analysis for that branch. The key is
71// read from `cwd` only when it holds the followed repo, else it is the repo.
72const readGate = async ($: EngineInterface, cwd: string | undefined, repo: string, ref: { branch?: string; pullRequest?: string }) => {
73  const properties = cwd ? await $.fs.read(`${cwd}/sonar-project.properties`).catch(() => '') : ''
74  const projectKey = sonarKeyOf(typeof properties === 'string' ? properties : '') ?? repo
75  const r = await $.tool.call({ tool: SONAR_GATE, projectKey, ...ref }).catch(() => undefined)
76  if (!r || ('deny' in r && r.deny !== undefined) || r.isError || typeof r.text !== 'string') return null
77  try {
78    return toGate(JSON.parse(r.text) as GateJson)
79  } catch {
80    return null
81  }
82}
83
84const poll = async ($: EngineInterface) => {
85  const target = await read($, dir)
86  const targetPr = await read($, pr)
87  // A poll that outlives a /ci to another repo or PR drops what it found.
88  const isTarget = async () => (await read($, dir)) === target && (await read($, pr)) === targetPr
89  const write = async (value: Build | null) => {
90    if (await isTarget()) await update($, build, () => value)
91  }
92  const cwd = target ?? (await $.session.cwd())
93  const [prRepo, prNumber] = targetPr?.split('#') ?? []
94  const ownRepo = repoOf(await run($, cwd, ['git', 'remote', 'get-url', 'origin']))
95  const repo = prRepo || ownRepo
96  const branch = prNumber ? '' : await run($, cwd, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
97  const found = !repo
98    ? 'missing'
99    : prNumber
100      ? await tryJob($, repo, `PR-${prNumber}`)
101      : branch && branch !== 'HEAD'
102        ? await findRun($, cwd, repo, branch)
103        : 'missing'
104  if (found === 'failed') return
105  if (found === 'missing' || !repo) return write(null)
106
107  const { url, runJson, label, sonarRef } = found
108  const before = await read($, build)
109  const isSame = before?.label === label && before.number === runJson.number
110  // Stages only change while the build runs: a finished one keeps its list.
111  let stages: StagesJson = { stages: before?.stages }
112  if (!isSame || before.isBuilding || runJson.building) {
113    const got = await getJson<StagesJson>($, `${url}/${runJson.number}/wfapi/describe`)
114    if (got === 'failed') return
115    stages = got === 'missing' ? {} : got.data
116  }
117  const now = toBuild(label, url, runJson, stages, Date.now())
118
119  const sinceEnd = Date.now() - now.endedAt
120  const isFresh = !now.isBuilding && sinceEnd < GATE_FRESH_MS
121  if (!now.isBuilding && sonarRan(now.stages) && sinceEnd >= GATE_SETTLE_MS) {
122    // Read while fresh, or once for a build first seen already old; then kept.
123    now.gate = isSame && !isFresh && before.gate !== undefined ? before.gate : await readGate($, repo === ownRepo ? cwd : undefined, repo, sonarRef)
124  }
125
126  if (isSame && before.isBuilding && !now.isBuilding)
127    $.ui.toast(`${now.result === 'SUCCESS' ? '✓' : '✗'} CI ${label} #${now.number} : ${now.result ?? 'terminé'}`)
128  // Only for a build that just ended: an old red gate seen at start or after
129  // /ci is on the band, never a toast.
130  if (isFresh && now.gate?.status === 'ERROR' && (!isSame || before.gate?.status !== 'ERROR'))
131    $.ui.toast(`✗ Sonar ${label} : quality gate en échec`)
132  // Read here, once per build, so that `f` never waits on Jenkins.
133  if (!now.isBuilding && (now.result === 'FAILURE' || now.result === 'UNSTABLE')) {
134    if (isSame && before.consoleTail !== undefined) now.consoleTail = before.consoleTail
135    else {
136      const r = await $.http.fetch(`${now.url}consoleText`).catch(() => undefined)
137      now.consoleTail = r?.ok ? tail(r.text) : null
138    }
139  }
140  await write(now)
141  // A build turning broken arms `f` until the next prompt is sent, so a
142  // message starting with f is only caught right after the band turns red.
143  if (isFixable(now) && !(isSame && isFixable(before)) && (await isTarget())) await update($, armed, () => true)
144}
145
146const isOnBand = async ($: EngineInterface, b: Build) =>
147  b.isBuilding || Date.now() - Math.max(b.endedAt, await read($, askedAt)) <= SHOW_DONE_MS
148
149// The band's build, when it is on the band and broken.
150const shown = async ($: EngineInterface) => {
151  const b = await read($, build)
152  return b && isFixable(b) && (await isOnBand($, b)) ? b : null
153}
154
155// The failure as a prompt for the box, its error lines only; the log's end
156// waits to be attached when that prompt is sent.
157const fixText = async ($: EngineInterface) => {
158  const b = await shown($)
159  if (!b) return undefined
160  const log = b.consoleTail ?? undefined
161  await update($, attachment, () => (log ? { header: fixHeader(b), context: logContext(b, log) } : null))
162  return fixPrompt(b, log ? errorLines(log) : undefined)
163}
164
165const fillFix = async ($: EngineInterface) => {
166  const text = await fixText($)
167  if (text) await $.prompt.fill({ text })
168}
169
170// One poll at a time, timer and /ci alike: a tick during a poll joins it.
171// One timer per module, so a second session.start replaces it instead of
172// adding a loop.
173let polling: Promise<void> | undefined
174let timer: Timer | undefined
175let clockTimer: Timer | undefined
176const tick = ($: EngineInterface) =>
177  (polling ??= poll($)
178    .catch(() => undefined)
179    .finally(() => {
180      polling = undefined
181    }))
182
183export const register: Register = on => {
184  on('session.start', async ($, e, next) => {
185    const r = await next(e)
186    await $.command.register({
187      name: 'ci',
188      description: "Follow the Jenkins build of a PR (number or URL) or of the repo at <path> (nothing: the session's directory)",
189      argumentHint: '[pr | path]',
190    })
191    void tick($)
192    timer?.cancel()
193    timer = $.clock.every(POLL_MS, () => void tick($))
194    clockTimer?.cancel()
195    clockTimer = $.clock.every(1000, () => {
196      void (async () => {
197        if ((await read($, build))?.isBuilding) await update($, second, n => n + 1)
198      })().catch(() => undefined)
199    })
200    return r
201  })
202
203  on('command.run', { command: 'ci' }, async ($, e) => {
204    const arg = e.args.trim()
205    const number = prOf(arg) ?? null
206    const path = number ? null : arg.replace(/^~(?=\/|$)/, (await $.env.get('HOME')) ?? '~') || null
207    await update($, dir, () => path)
208    await update($, pr, () => number)
209    await update($, build, () => null)
210    await update($, askedAt, () => Date.now())
211    // A poll still running read the old target and drops what it found: wait
212    // for it, then poll the new one, so the reply says what was found.
213    await polling
214    await tick($)
215    const b = await read($, build)
216    const what = number ?? path ?? 'le répertoire de la session'
217    if (!b) return { text: `CI suivie : ${what} · aucun build Jenkins trouvé` }
218    const state = b.isBuilding ? 'en cours' : (b.result ?? 'terminé')
219    return { text: `CI suivie : ${b.label} #${b.number} · ${state} · ${b.url}` }
220  })
221
222  // A letter typed at the prompt never presses a band Button: `f`, in an
223  // empty prompt while armed, is caught here; otherwise the band's button.
224  on('prompt.edit', async ($, e, next) => {
225    const isKey = e.text === '' && e.inputText.toLowerCase() === 'f' && (await read($, armed))
226    const text = isKey ? await fixText($) : undefined
227    return text ? { text, cursor: text.length } : next(e)
228  })
229
230  // The log goes with the prepared prompt only: the box emptied or retyped
231  // sends nothing more. Any submit spends it, and disarms `f`.
232  on('prompt.submit', async ($, e, next) => {
233    await update($, armed, () => false)
234    const a = await read($, attachment)
235    if (!a) return next(e)
236    await update($, attachment, () => null)
237    return e.text.includes(a.header) ? next({ ...e, context: [...(e.context ?? []), a.context] }) : next(e)
238  })
239
240  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
241    const below = await next(e)
242    const b: Build | null = await read($, build)
243    await read($, second)
244    if (e.props.hasSurvey || !b || !(await isOnBand($, b))) return below
245    const { Box, Text, Link, Button } = $.ui.resolve(e)
246    const failed = b.stages.find(s => s.status === 'FAILED' || s.status === 'UNSTABLE')
247    const current = b.stages.find(s => s.status === 'IN_PROGRESS' || s.status === 'PAUSED_PENDING_INPUT')
248    const color = frameColor(b.isBuilding, b.result)
249    const now = live(b, Date.now())
250
251    const head = b.isBuilding ? (
252      <Box flexDirection="column">
253        <Text>
254          <Text color={color}>● CI {b.label} #{b.number}</Text>
255          {b.hasEstimate ? (
256            <Text>
257              {'  '}
258              <Text color={color}>{bar(now.percent)}</Text> {now.percent} %
259            </Text>
260          ) : (
261            ''
262          )}
263        </Text>
264        <Text dimColor>
265          {'  '}
266          {current ? `${current.name} · ` : ''}
267          {minutes(now.elapsedMs)}
268          {b.hasEstimate ? ` · reste ~${minutes(now.remainingMs)}` : ''}
269        </Text>
270      </Box>
271    ) : (
272      <Text>
273        <Text color={color}>
274          {b.result === 'SUCCESS' ? '✓' : '✗'} CI {b.label} #{b.number} {b.result === 'SUCCESS' ? 'réussi' : (b.result ?? 'terminé').toLowerCase()}
275        </Text>
276        {failed ? ` à l'étape ${failed.name}` : ''}
277        <Text dimColor> en {minutes(b.durationMs)}</Text>
278      </Text>
279    )
280
281    return (
282      <Box flexDirection="column">
283        {below}
284        <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
285          {head}
286          {b.stages.length > 0 && (
287            <Text wrap="truncate-end">
288              {b.stages.map(s => (
289                <Text
290                  color={s.status === 'SUCCESS' ? 'green' : s.status === 'IN_PROGRESS' ? 'cyan' : s.status === 'FAILED' ? 'red' : undefined}
291                  dimColor={s.status === 'NOT_EXECUTED'}
292                >
293                  {stageMark(s.status)} {s.name}
294                  {s.status === 'IN_PROGRESS' || s.status === 'NOT_EXECUTED' || !s.durationMs ? '' : ` ${short(s.durationMs)}`}
295                  {'  '}
296                </Text>
297              ))}
298            </Text>
299          )}
300          {b.gate && (
301            <Text wrap="truncate-end">
302              <Text color={b.gate.status === 'OK' ? 'green' : b.gate.status === 'ERROR' ? 'red' : undefined}>
303                Sonar {b.gate.status === 'OK' ? '✓' : b.gate.status === 'ERROR' ? '✗' : '○'} quality gate {b.gate.status}
304              </Text>
305              {b.gate.failed.map(c => ` · ${c.metric} ${c.actual} (seuil ${c.threshold})`).join('')}
306            </Text>
307          )}
308          <Link href={b.url} label={`↗ ouvrir le build #${b.number} dans Jenkins`} />
309          {isFixable(b) && (
310            <Box>
311              <Button key="fix" label="préparer le prompt de correction" hotkey="f" plain onPress={() => void fillFix($)} />
312              {(await read($, armed)) ? <Text dimColor> (f, prompt vide)</Text> : <Text dimColor> (ctrl+x tab puis f)</Text>}
313            </Box>
314          )}
315        </Box>
316      </Box>
317    )
318  })
319}
320
hooks/jenkins.ts 159 lines
1import type { Build, Gate } from '../types'
2
3// ponytail: one Jenkins and one folder for every repo; userConfig fields when
4// a second Jenkins or folder shows up.
5export const JENKINS = 'https://jenkins.vidal.net'
6const FOLDER = 'job/team.software/job/github'
7const ORG = 'softwarevidal'
8
9// `git@github.com:softwarevidal/vidal-mcp.git` or the https form → `vidal-mcp`.
10export const repoOf = (remote: string): string | undefined =>
11  remote.trim().match(new RegExp(`github\\.com[:/]${ORG}/([^/\\s]+?)(?:\\.git)?$`))?.[1]
12
13// `/ci 231`, `/ci #231` or a PR's URL → `#231` or `data-bridge#231`; any
14// other argument is a path.
15export const prOf = (arg: string): string | undefined => {
16  const url = arg.match(new RegExp(`github\\.com/${ORG}/([^/\\s]+)/pull/(\\d+)`))
17  if (url) return `${url[1]}#${url[2]}`
18  return arg.match(/^#?\d+$/) ? `#${arg.replace('#', '')}` : undefined
19}
20
21// A multibranch job is named after the branch with `/` escaped as %2F, and
22// that name is escaped again in the URL: feat/x → job/feat%252Fx.
23export const jobUrl = (repo: string, job: string) =>
24  `${JENKINS}/${FOLDER}/job/${repo}/job/${encodeURIComponent(encodeURIComponent(job))}`
25
26export type RunJson = { number: number; building: boolean; result: string | null; timestamp: number; estimatedDuration: number; duration: number }
27export type StagesJson = { stages?: { name: string; status: string; durationMillis?: number }[] }
28
29export const toBuild = (label: string, url: string, run: RunJson, wf: StagesJson, now: number): Build => {
30  const estimated = run.estimatedDuration > 0 ? run.estimatedDuration : 0
31  const progress = live({ startedAt: run.timestamp, estimatedMs: estimated }, now)
32  return {
33    label,
34    number: run.number,
35    url: `${url}/${run.number}/`,
36    isBuilding: run.building,
37    startedAt: run.timestamp,
38    estimatedMs: estimated,
39    result: run.result,
40    hasEstimate: estimated > 0,
41    percent: run.building ? progress.percent : 100,
42    remainingMs: run.building ? progress.remainingMs : 0,
43    durationMs: run.building ? progress.elapsedMs : run.duration,
44    endedAt: run.building ? 0 : run.timestamp + run.duration,
45    stages: (wf.stages ?? []).map(s => ({ name: s.name, status: s.status, durationMs: s.durationMillis ?? 0 })),
46    gate: undefined,
47  }
48}
49
50export const bar = (percent: number, width = 20) => {
51  const full = Math.round((percent / 100) * width)
52  return '█'.repeat(full) + '░'.repeat(width - full)
53}
54
55export const minutes = (ms: number) => {
56  const s = Math.round(ms / 1000)
57  return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')}`
58}
59
60// wfapi statuses: SUCCESS, FAILED, UNSTABLE, ABORTED, IN_PROGRESS, PAUSED_PENDING_INPUT, NOT_EXECUTED.
61export const stageMark = (status: string) =>
62  ({ SUCCESS: '✓', FAILED: '✗', UNSTABLE: '!', ABORTED: '■', IN_PROGRESS: '●', PAUSED_PENDING_INPUT: '?' })[status] ?? '○'
63
64// `sonar.projectKey=vidal-mcp` in sonar-project.properties.
65export const sonarKeyOf = (properties: string) => properties.match(/^\s*sonar\.projectKey\s*=\s*(\S+)/m)?.[1]
66
67// What the SonarQube MCP tool get_project_quality_gate_status answers.
68export type GateJson = { status: string; conditions?: { metricKey: string; status: string; actualValue?: string; errorThreshold?: string }[] }
69
70export const toGate = (json: GateJson): Gate => ({
71  status: json.status,
72  failed: (json.conditions ?? [])
73    .filter(c => c.status === 'ERROR')
74    .map(c => ({ metric: c.metricKey, actual: c.actualValue ?? '?', threshold: c.errorThreshold ?? '?' })),
75})
76
77// The gate only belongs to this build once its own Sonar stage ran: a build
78// that failed earlier would show the previous analysis's gate.
79export const sonarRan = (stages: readonly { name: string; status: string }[]) =>
80  stages.some(s => /sonar/i.test(s.name) && s.status === 'SUCCESS')
81
82// Where a running build stands at `now`, between two polls.
83export function live(b: { startedAt: number; estimatedMs: number }, now: number) {
84  const elapsedMs = Math.max(0, now - b.startedAt)
85  return {
86    elapsedMs,
87    // An estimate only: a build that runs past it holds at 99 %.
88    percent: b.estimatedMs ? Math.min(99, Math.floor((elapsedMs / b.estimatedMs) * 100)) : 0,
89    remainingMs: b.estimatedMs ? Math.max(0, b.estimatedMs - elapsedMs) : 0,
90  }
91}
92
93// `4s`, `1m12`: a stage's duration, short enough for the stage strip.
94export const short = (ms: number) => {
95  const s = Math.round(ms / 1000)
96  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}`
97}
98
99// The band's frame: cyan while running, then the result's colour.
100export const frameColor = (isBuilding: boolean, result: string | null) =>
101  isBuilding ? 'cyan' : result === 'SUCCESS' ? 'green' : result === 'FAILURE' ? 'red' : 'yellow'
102
103// A failed or unstable build, or a red gate: worth handing to Claude. An
104// aborted build was stopped by someone, not broken.
105export const isFixable = (b: Build) =>
106  !b.isBuilding && (b.result === 'FAILURE' || b.result === 'UNSTABLE' || b.gate?.status === 'ERROR')
107
108// ponytail: the whole console, cut to its end, where the error and the
109// output leading to it are; per-stage logs (wfapi/log) if consoles get huge.
110export const tail = (text: string, lines = 150) => text.trimEnd().split('\n').slice(-lines).join('\n')
111
112// The lines worth reading in the box: those naming an error, each with the
113// line before it unless blank or the pipeline's own chatter, the last `max`
114// kept; with none, the log's last lines. Jenkins' own echoes of the failure
115// (skipped stages, the final status) say nothing new.
116const ERROR_LINE = /\b(error|exception|failed|failure|assertionerror|exit code)\b/i
117const ECHO_LINE = /skipped due to earlier failure|^Finished: /
118export const errorLines = (log: string, max = 10) => {
119  const lines = log.split('\n')
120  const kept = new Set<number>()
121  lines.forEach((line, i) => {
122    if (!ERROR_LINE.test(line) || ECHO_LINE.test(line)) return
123    const before = lines[i - 1]
124    if (before?.trim() && !before.startsWith('[Pipeline]') && !ECHO_LINE.test(before)) kept.add(i - 1)
125    kept.add(i)
126  })
127  const picked = [...kept].sort((a, b) => a - b).map(i => lines[i]!)
128  return (picked.length ? picked : lines).slice(-max).join('\n')
129}
130
131// The box's first line, also how the submit hook knows the prompt is still
132// the one `f` prepared and attaches the log to it.
133export const fixHeader = (b: Build) => {
134  const failed = b.stages.find(s => s.status === 'FAILED' || s.status === 'UNSTABLE')
135  return `Le build Jenkins ${b.label} #${b.number} est en ${b.result}${failed ? ` à l'étape ${failed.name}` : ''} : ${b.url}`
136}
137
138// What the band's `f` puts in the box: the failed step with its error lines,
139// and the gate's broken conditions, for Claude to read up on and fix.
140export const fixPrompt = (b: Build, excerpt: string | undefined) => {
141  const parts: string[] = []
142  if (b.result === 'FAILURE' || b.result === 'UNSTABLE') {
143    parts.push(fixHeader(b), excerpt ? `Erreurs :\n\`\`\`\n${excerpt}\n\`\`\`` : `Log : ${b.url}consoleText`)
144  }
145  if (b.gate?.status === 'ERROR') {
146    const conditions = b.gate.failed.map(c => `${c.metric} ${c.actual} (seuil ${c.threshold})`).join(', ')
147    parts.push(
148      `La quality gate SonarQube de ${b.label} est en échec : ${conditions || 'conditions inconnues'}. ` +
149        'Lis les issues et la couverture concernées avec le MCP SonarQube.',
150    )
151  }
152  return [...parts, 'Trouve la cause et corrige-la.'].join('\n\n')
153}
154
155// Attached to the prompt when sent, out of the box: what Claude reads the
156// error lines against.
157export const logContext = (b: Build, log: string) =>
158  `Les dernières lignes du log Jenkins de ${b.label} #${b.number} :\n\`\`\`\n${log}\n\`\`\``
159
types/index.d.ts 48 lines
1// durationMs: as Jenkins reports it, so far for a running stage.
2export type Stage = { name: string; status: string; durationMs: number }
3
4// The SonarQube quality gate of the build's branch or PR; failed lists the
5// conditions that broke it.
6export type Gate = { status: string; failed: { metric: string; actual: string; threshold: string }[] }
7
8// The last build of the current branch's job, as the band draws it.
9export type Build = {
10  label: string
11  number: number
12  url: string
13  isBuilding: boolean
14  // Kept so the band can move the clock every second between polls.
15  startedAt: number
16  estimatedMs: number
17  result: string | null
18  hasEstimate: boolean
19  percent: number
20  remainingMs: number
21  durationMs: number
22  endedAt: number
23  stages: Stage[]
24  // undefined until read; null when read and there is none.
25  gate?: Gate | null
26  // A failed or unstable build's console end, read once by the poll so that
27  // `f` has nothing to fetch; null when Jenkins did not give it.
28  consoleTail?: string | null
29}
30
31declare module 'claude-code' {
32  interface PluginState {
33    // attachment: the log tail joined to the prompt `f` prepared, once sent;
34    // header is that prompt's first line. armed: whether `f` typed in an
35    // empty prompt is caught, from a build turning broken to the next submit.
36    'ci-watch': {
37      build: Build | null
38      dir: string | null
39      // `#231` or `data-bridge#231`, from /ci.
40      pr: string | null
41      second: number
42      askedAt: number
43      attachment: { header: string; context: string } | null
44      armed: boolean
45    }
46  }
47}
48