Band above the prompt with the GitHub Actions / GitLab CI pipelines of the current session: stepped progress bar per stage, expandable jobs, retry (GitLab via…

A Claude Code mod that shows the GitHub Actions / GitLab CI pipelines of the current session in a band right above the prompt, terminal and desktop alike.
git push, gh pr create, glab mr create run by Claude); one line per branch, its latest pipeline.●━━━━◉────○. Set style to rail for filled segments instead.⌄ / ⌃) to open the detail: stages and their jobs with status and duration, ↻ to retry a job, ↻ Relancer les échecs for the failed jobs, Ouvrir ↗ for the pipeline page.● feat: login form feat/login ●━━━━━━━━━━━◉───────────○ test 62% ⌄
✓ chore: deps main ●━━━━━━━━━━━●━━━━━━━━━━━● terminé 100% ⌃
✓ build 1/1
✓ docker-build 1m02 ↻
✓ test 2/2
✓ lint 18s ↻
✓ unit 41s ↻
✓ deploy 1/1
✓ update-helm 12s ↻
Ouvrir ↗ 2h · via MCP GitLab local
origin points at GitHub or GitLab.gh authenticated (gh auth login).https://<gitlab>/api/v4/mcp) connected in Claude — default name plugin:gitlab:gitlab (the GitLab plugin). It lists pipelines and jobs and retries a pipeline's failed jobs; it has no single-job retry, so that falls through to the next backend;list_pipelines, list_pipeline_jobs, retry_pipeline_job, retry_pipeline) — default name gitlab-forge. Its pipelines toolset is activated through discover_tools when the server has one, and the project's numeric id is looked up with list_projects (some servers allow-list projects by id only);glab authenticated (glab auth login --hostname <host>).A backend that fails (not connected, project unknown to it, no token) is skipped for 5 minutes for reads, and the next one answers. The open detail says which one served it (via MCP GitLab local). Other servers exposing save_pipeline (official) or retry_pipeline_job (local) are picked up on their own.
In a Claude Code terminal session:
/plugin install ci-jobs --marketplace MagnasiePro/claude-ci-jobs
Answer y to add the marketplace, then pick a scope (user scope first). The mod also loads in Claude desktop's local sessions once installed at the user scope.
/ci-jobs refreshes it, /ci-jobs refresh also re-reads the repository. The engine's [-] folds the band./config → ci-jobs.*):pollSeconds (default 20): refresh interval.style (stepper or rail, default stepper): stage graph or filled rail.gitlabMcpOfficial (default plugin:gitlab:gitlab): official GitLab MCP server name(s) as /mcp lists them, comma-separated.gitlabMcpLocal (default gitlab-forge): local GitLab MCP server name(s), comma-separated.claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.tsx 539 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register } from 'claude-code'
3
4import type { Job, Pipeline, Repo, Status } from '../types'
5import { RAIL_H, STATUS_HEX, STEPPER_H, progressOf, railSvg, stepperSvg, textRail, textStepper } from './track'
6import { BACKEND_LABEL, detectServers, glabArgv, localCall, mcpData, officialCall } from './gitlab'
7import type { BackendKind, GitlabOp, GitlabServers } from './gitlab'
8import {
9 formatAge,
10 formatDuration,
11 latestPerBranch,
12 stagesOf,
13 glJobs,
14 glPipelines,
15 isActive,
16 jobsArgv,
17 listArgv,
18 parseJobs,
19 parsePipelines,
20 parseRemote,
21 pushedBranch,
22 retryFailedArgv,
23 retryJobArgv,
24} from './providers'
25
26
27const repoAtom = atom({ plugin: 'ci-jobs', key: 'repo' } as const, null)
28const branches = atom({ plugin: 'ci-jobs', key: 'branches' } as const, [])
29const pipelines = atom({ plugin: 'ci-jobs', key: 'pipelines' } as const, [])
30const expanded = atom({ plugin: 'ci-jobs', key: 'expanded' } as const, {})
31const busy = atom({ plugin: 'ci-jobs', key: 'busy' } as const, {})
32const errorAtom = atom({ plugin: 'ci-jobs', key: 'error' } as const, null)
33const updatedAt = atom({ plugin: 'ci-jobs', key: 'updatedAt' } as const, 0)
34const via = atom({ plugin: 'ci-jobs', key: 'via' } as const, null)
35
36type Dollar = EngineInterface
37
38const FIGURE_SPACE = String.fromCharCode(0x2007)
39/** plan-progress' glyphs: running, waiting on a person, failed, done. */
40const GLYPH: Record<Status, string> = {
41 running: '●',
42 pending: '○',
43 manual: '?',
44 failed: '!',
45 success: '✓',
46 canceled: '–',
47 skipped: '–',
48 unknown: '·',
49}
50
51const BINARIES: Record<'gh' | 'glab', string[]> = {
52 gh: ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh'],
53 glab: ['glab', '/opt/homebrew/bin/glab', '/usr/local/bin/glab'],
54}
55const found: Partial<Record<'gh' | 'glab', string>> = {}
56
57/** Runs gh or glab, trying the usual install places when PATH lacks them. */
58async function cli($: Dollar, name: 'gh' | 'glab', args: string[]): Promise<ProcessRunResult> {
59 const known = found[name]
60 const tries = known ? [known] : BINARIES[name]
61 let last: unknown
62 for (const bin of tries) {
63 try {
64 const ran = await $.process.run([bin, ...args], { timeoutMs: 30_000 })
65 found[name] = bin
66
67 return ran
68 } catch (err) {
69 last = err
70 }
71 }
72 throw last instanceof Error ? last : new Error(`${name} introuvable`)
73}
74
75function failure(ran: ProcessRunResult): string {
76 return (ran.stderr || ran.stdout).trim().split('\n').slice(-2).join(' ') || `exit ${ran.exitCode}`
77}
78
79const SKIP_MS = 5 * 60_000
80const skipUntil = new Map<string, number>()
81const projectIds = new Map<string, string>()
82const activated = new Set<string>()
83
84/** Runs a GitLab op down the chain: official MCP, local MCP, then glab. */
85async function runGitlab(
86 $: Dollar,
87 servers: GitlabServers,
88 repo: Repo,
89 op: GitlabOp,
90): Promise<{ data: unknown; backend: BackendKind }> {
91 const errors: string[] = []
92 const isRead = op.op === 'list' || op.op === 'jobs'
93 const isSkipped = (key: string) => isRead && (skipUntil.get(`${key}|${repo.host}/${repo.path}`) ?? 0) > Date.now()
94 const failed = (kind: BackendKind, key: string, err: unknown) => {
95 errors.push(`${BACKEND_LABEL[kind]}: ${err instanceof Error ? err.message : String(err)}`)
96 if (isRead) skipUntil.set(`${key}|${repo.host}/${repo.path}`, Date.now() + SKIP_MS)
97 }
98
99 const official = officialCall(repo, op)
100 for (const server of official ? servers.official : []) {
101 if (!official || isSkipped(server)) continue
102 try {
103 return { data: mcpData(await $.mcp.call(server, official.tool, official.args)), backend: 'mcp-official' }
104 } catch (err) {
105 failed('mcp-official', server, err)
106 }
107 }
108 for (const server of servers.local) {
109 if (isSkipped(server)) continue
110 try {
111 const call = localCall(await localProjectId($, server, repo), op)
112
113 return { data: mcpData(await $.mcp.call(server, call.tool, call.args)), backend: 'mcp-local' }
114 } catch (err) {
115 failed('mcp-local', server, err)
116 }
117 }
118 if (!isSkipped('glab')) {
119 try {
120 const ran = await cli($, 'glab', glabArgv(repo, op))
121 if (ran.exitCode !== 0) throw new Error(failure(ran))
122
123 return { data: JSON.parse(ran.stdout || 'null'), backend: 'glab' }
124 } catch (err) {
125 failed('glab', 'glab', err)
126 }
127 }
128 throw new Error(errors.length > 0 ? errors.join(' | ') : 'aucun backend GitLab disponible')
129}
130
131/**
132 * The project's numeric id on a local server (some allow-list projects by id
133 * only), found among the projects the user belongs to.
134 */
135async function localProjectId($: Dollar, server: string, repo: Repo): Promise<string> {
136 const cacheKey = `${server}|${repo.path}`
137 const cached = projectIds.get(cacheKey)
138 if (cached) return cached
139 if (!activated.has(server)) {
140 activated.add(server)
141 try {
142 await $.mcp.call(server, 'discover_tools', { category: 'pipelines' })
143 } catch {
144 // servers without opt-in toolsets expose the pipeline tools already
145 }
146 }
147 const name = repo.path.split('/').pop() ?? repo.path
148 const data = mcpData(
149 await $.mcp.call(server, 'list_projects', { search: name, membership: true, simple: true, per_page: 100 }),
150 )
151 const list = Array.isArray(data) ? (data as Record<string, unknown>[]) : []
152 const hit = list.find(project => project.path_with_namespace === repo.path)
153 if (!hit || hit.id === undefined) throw new Error(`projet ${repo.path} introuvable`)
154 const id = String(hit.id)
155 projectIds.set(cacheKey, id)
156
157 return id
158}
159
160async function git($: Dollar, args: string[]): Promise<string | undefined> {
161 try {
162 const ran = await $.process.run(['git', ...args], { timeoutMs: 10_000 })
163
164 return ran.exitCode === 0 ? ran.stdout.trim() : undefined
165 } catch {
166 return undefined
167 }
168}
169
170async function currentBranch($: Dollar): Promise<string | undefined> {
171 const head = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])
172
173 return head && head !== 'HEAD' ? head : undefined
174}
175
176async function track($: Dollar, branch: string | undefined): Promise<void> {
177 if (!branch) return
178 await update($, branches, list => (list.includes(branch) ? list : [branch, ...list]))
179}
180
181async function detectRepo($: Dollar): Promise<Repo | null> {
182 const url = await git($, ['remote', 'get-url', 'origin'])
183 const repo = url ? parseRemote(url) ?? null : null
184 await update($, repoAtom, () => repo)
185
186 return repo
187}
188
189let servers: GitlabServers = { official: [], local: [] }
190/** `style` option: the stage graph (default) or the filled rail. */
191let isStepper = true
192
193/** Runs a GitLab op down the chain and notes which backend answered. */
194async function viaGitlab($: Dollar, repo: Repo, op: GitlabOp): Promise<unknown> {
195 const { data, backend } = await runGitlab($, servers, repo, op)
196 const label = BACKEND_LABEL[backend]
197 if ((await read($, via)) !== label) await update($, via, () => label)
198
199 return data
200}
201
202async function listPipelines($: Dollar, repo: Repo, branch: string): Promise<Pipeline[]> {
203 if (repo.provider === 'gitlab') return glPipelines(await viaGitlab($, repo, { op: 'list', branch, perPage: 5 }), branch)
204 const ran = await cli($, 'gh', listArgv(repo, branch))
205 if (ran.exitCode !== 0) throw new Error(failure(ran))
206 if ((await read($, via)) !== 'gh') await update($, via, () => 'gh')
207
208 return parsePipelines(repo, ran.stdout, branch)
209}
210
211async function fetchJobs($: Dollar, repo: Repo, pipe: Pipeline): Promise<Job[] | undefined> {
212 if (repo.provider === 'gitlab') return glJobs(await viaGitlab($, repo, { op: 'jobs', pipelineId: pipe.id }))
213 const ran = await cli($, 'gh', jobsArgv(repo, pipe.id))
214 if (ran.exitCode !== 0) throw new Error(failure(ran))
215
216 return parseJobs(repo, ran.stdout)
217}
218
219let refreshing: Promise<void> | undefined
220
221/** Re-reads every tracked branch's pipelines, and the jobs of open or running ones. */
222function refresh($: Dollar): Promise<void> {
223 refreshing ??= doRefresh($).finally(() => {
224 refreshing = undefined
225 })
226
227 return refreshing
228}
229
230async function doRefresh($: Dollar): Promise<void> {
231 const repo = (await read($, repoAtom)) ?? (await detectRepo($))
232 if (!repo) {
233 await update($, errorAtom, () => 'Pas de remote git « origin » GitHub/GitLab dans ce dossier.')
234 return
235 }
236 await track($, await currentBranch($))
237 const tracked = await read($, branches)
238 const open = await read($, expanded)
239 const before = new Map((await read($, pipelines)).map(pipe => [pipe.key, pipe]))
240 const errors: string[] = []
241 const next: Pipeline[] = []
242
243 for (const branch of tracked) {
244 try {
245 next.push(...(await listPipelines($, repo, branch)))
246 } catch (err) {
247 errors.push(`${branch}: ${err instanceof Error ? err.message : String(err)}`)
248 next.push(...[...before.values()].filter(pipe => pipe.branch === branch))
249 }
250 }
251
252 const seen = new Set<string>()
253 const list = next
254 .filter(pipe => (seen.has(pipe.key) ? false : (seen.add(pipe.key), true)))
255 .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
256
257 const shown = new Set(latestPerBranch(list).map(pipe => pipe.key))
258 for (const pipe of list) {
259 const old = before.get(pipe.key)
260 const isStale = !old || old.status !== pipe.status || isActive(pipe.status)
261 pipe.jobs = isStale ? undefined : old.jobs
262 if (!isStale || (!shown.has(pipe.key) && open[pipe.key] !== true)) continue
263 try {
264 pipe.jobs = await fetchJobs($, repo, pipe)
265 } catch (err) {
266 errors.push(`${pipe.title}: ${err instanceof Error ? err.message : String(err)}`)
267 }
268 }
269
270 await update($, pipelines, () => list)
271 await update($, errorAtom, () => (errors.length > 0 ? errors.join('\n') : null))
272 await update($, updatedAt, () => Date.now())
273}
274
275async function toggle($: Dollar, key: string): Promise<void> {
276 const state = await update($, expanded, map => ({ ...map, [key]: !map[key] }))
277 if (!state[key]) return
278 const repo = await read($, repoAtom)
279 const pipe = (await read($, pipelines)).find(one => one.key === key)
280 if (!repo || !pipe || pipe.jobs) return
281 try {
282 const jobs = await fetchJobs($, repo, pipe)
283 await update($, pipelines, list => list.map(one => (one.key === key ? { ...one, jobs } : one)))
284 } catch (err) {
285 $.ui.toast(`CI : ${err instanceof Error ? err.message : String(err)}`)
286 }
287}
288
289type RetryOp = Extract<GitlabOp, { op: 'retryJob' | 'retryFailed' }>
290
291async function retry($: Dollar, busyKey: string, op: RetryOp, label: string): Promise<void> {
292 const repo = await read($, repoAtom)
293 if (!repo) return
294 if ((await read($, busy))[busyKey]) return
295 await update($, busy, map => ({ ...map, [busyKey]: true }))
296 try {
297 if (repo.provider === 'gitlab') {
298 await viaGitlab($, repo, op)
299 $.ui.toast(`CI : ${label} relancé`)
300 } else {
301 const argv = op.op === 'retryJob' ? retryJobArgv(repo, op.pipelineId, op.jobId) : retryFailedArgv(repo, op.pipelineId)
302 const ran = await cli($, 'gh', argv)
303 $.ui.toast(ran.exitCode === 0 ? `CI : ${label} relancé` : `CI : échec — ${failure(ran)}`)
304 }
305 } catch (err) {
306 $.ui.toast(`CI : échec — ${err instanceof Error ? err.message : String(err)}`)
307 } finally {
308 await update($, busy, map => {
309 const { [busyKey]: _, ...rest } = map
310 return rest
311 })
312 }
313 await refresh($)
314 $.clock.after(5_000, () => void refresh($))
315}
316
317const names = (value: unknown, fallback: string): string[] =>
318 String(value ?? fallback)
319 .split(',')
320 .map(name => name.trim())
321 .filter(Boolean)
322
323export const register: Register = (on, options) => {
324 const pollMs = Math.max(5, Number(options.pollSeconds ?? 20)) * 1000
325 isStepper = options.style !== 'rail'
326 const configured: GitlabServers = {
327 official: names(options.gitlabMcpOfficial, 'plugin:gitlab:gitlab'),
328 local: names(options.gitlabMcpLocal, 'gitlab-forge'),
329 }
330 servers = configured
331
332 on('session.start', async ($, e, next) => {
333 await $.command.register({
334 name: 'ci-jobs',
335 description: 'Rafraîchit la bande CI (pipelines GitHub/GitLab de la session)',
336 argumentHint: '[refresh]',
337 })
338 try {
339 servers = detectServers(configured, (await $.tool.list()).map(tool => tool.name))
340 } catch {
341 servers = configured
342 }
343 await detectRepo($)
344 await track($, await currentBranch($))
345 // v0.2 drew in a side pane; the band above the prompt replaces it
346 void $.ui.close({ id: 'ci-jobs' }).catch(() => undefined)
347 void refresh($)
348 let tick = 0
349 $.clock.every(pollMs, () => {
350 tick += 1
351 void (async () => {
352 const isBusy = (await read($, pipelines)).some(pipe => isActive(pipe.status))
353 if (isBusy || tick % 4 === 0) await refresh($)
354 })()
355 })
356
357 return next(e)
358 })
359
360 on('command.run', { command: 'ci-jobs' }, async ($, e) => {
361 if (e.args.trim() === 'refresh') await detectRepo($)
362 await refresh($)
363 const count = latestPerBranch(await read($, pipelines)).length
364
365 return { text: `CI : ${count} pipeline${count > 1 ? 's' : ''} suivie${count > 1 ? 's' : ''}, au-dessus du prompt.` }
366 })
367
368 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
369 const ran = await next(e)
370 const push = pushedBranch(e.command)
371 if (push.isPush && !ran.isError && !ran.deny) {
372 await track($, push.branch ?? (await currentBranch($)))
373 $.clock.after(2_000, () => void refresh($))
374 $.clock.after(10_000, () => void refresh($))
375 }
376
377 return ran
378 }).catch(($, e, next) => next(e))
379
380 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
381 if (e.props.hasSurvey) return next(e)
382 const repo = await read($, repoAtom)
383 const list = latestPerBranch(await read($, pipelines))
384 const error = await read($, errorAtom)
385 if (!repo || (list.length === 0 && !error)) return next(e)
386
387 const t = $.ui.resolve(e)
388 const { Box, Text, Button, Link } = t
389 const Svg = 'Svg' in t ? t.Svg : null
390 const open = await read($, expanded)
391 const running = await read($, busy)
392 const backend = await read($, via)
393 const now = Date.now()
394 const shown = list.slice(0, 5)
395 // the title takes a fixed share of the row so every rail starts and ends at the same place
396 const cols = Math.max(30, e.props.bodyColumns || 100)
397 const TITLE_SHARE = cols < 70 ? 0.4 : 0.32
398 const railCells = Math.max(8, cols - Math.round(cols * TITLE_SHARE) - 24)
399
400 const rows = shown.map(pipe => {
401 const isOpen = open[pipe.key] === true
402 const stages = stagesOf(pipe.jobs ?? [])
403 const progress = progressOf(stages, pipe.status)
404 const color = STATUS_HEX[pipe.status]
405 const label = isActive(pipe.status)
406 ? (progress.current?.name ?? 'en attente')
407 : pipe.status === 'success'
408 ? 'terminé'
409 : (progress.current?.name ?? pipe.status)
410 const failed = stages.some(stage => stage.status === 'failed') || pipe.status === 'failed'
411 const onToggle = () => void toggle($, pipe.key)
412 const alt = `${pipe.title} (${pipe.branch}): ${label}, ${progress.percent}%`
413
414 return (
415 <Box key={`b-${pipe.key}`} flexDirection="column">
416 <Box flexDirection="row" alignItems="center" gap={1}>
417 <Text color={color}>{GLYPH[pipe.status]}</Text>
418 <Box width={`${Math.round(TITLE_SHARE * 100)}%`} flexShrink={0}>
419 <Button key={`t-${pipe.key}`} plain onPress={onToggle}>
420 <Text wrap="truncate">
421 {pipe.title} <Text dimColor>{pipe.branch}</Text>
422 </Text>
423 </Button>
424 </Box>
425 <Box flexGrow={1} flexShrink={1} minWidth={8}>
426 {Svg ? (
427 isStepper ? (
428 <Svg source={stepperSvg(stages, pipe.status)} alt={alt} height={STEPPER_H} isInteractive />
429 ) : (
430 <Svg source={railSvg(stages, pipe.status)} alt={alt} height={RAIL_H} isInteractive />
431 )
432 ) : (
433 <Text>
434 {(isStepper ? textStepper(stages, railCells) : textRail(stages, railCells)).map((part, i) =>
435 part.status === 'todo' ? (
436 <Text key={`r-${pipe.key}-${i}`} dimColor>
437 {part.text}
438 </Text>
439 ) : (
440 <Text key={`r-${pipe.key}-${i}`} color={STATUS_HEX[part.status]}>
441 {part.text}
442 </Text>
443 ),
444 )}
445 </Text>
446 )}
447 </Box>
448 <Box flexShrink={0}>
449 <Text color={color} wrap="truncate">
450 {label}
451 </Text>
452 </Box>
453 <Text dimColor>{`${String(progress.percent).padStart(3, FIGURE_SPACE)}%`}</Text>
454 <Button key={`p-${pipe.key}`} plain dimColor label={isOpen ? '⌃' : '⌄'} onPress={onToggle} />
455 </Box>
456 {isOpen && (
457 <Box flexDirection="column" marginLeft={2} marginTop={1}>
458 {!pipe.jobs && <Text dimColor>Chargement des jobs…</Text>}
459 {stages.map(stage => (
460 <Box key={`s-${pipe.key}-${stage.name}`} flexDirection="column">
461 <Text>
462 <Text color={STATUS_HEX[stage.status]}>{GLYPH[stage.status]}</Text> <Text bold>{stage.name}</Text>
463 <Text dimColor>
464 {' '}
465 {stage.done}/{stage.jobs.length}
466 </Text>
467 </Text>
468 {stage.jobs.map(job => (
469 <Box key={`j-${pipe.key}-${job.id}`} flexDirection="row" gap={1} marginLeft={2}>
470 <Text color={STATUS_HEX[job.status]}>{GLYPH[job.status]}</Text>
471 <Box flexGrow={1} flexShrink={1}>
472 <Text wrap="truncate">{job.name}</Text>
473 </Box>
474 <Text dimColor>{formatDuration(job.durationSec)}</Text>
475 {running[`job-${job.id}`] ? (
476 <Text dimColor>…</Text>
477 ) : (
478 <Button
479 key={`r-${pipe.key}-${job.id}`}
480 label="↻"
481 plain
482 dimColor
483 onPress={() =>
484 void retry($, `job-${job.id}`, { op: 'retryJob', pipelineId: pipe.id, jobId: job.id }, job.name)
485 }
486 />
487 )}
488 </Box>
489 ))}
490 </Box>
491 ))}
492 <Box flexDirection="row" gap={2} marginTop={1}>
493 {failed && !isActive(pipe.status) && (
494 <Button
495 key={`rf-${pipe.key}`}
496 plain
497 onPress={() =>
498 void retry($, `pipe-${pipe.key}`, { op: 'retryFailed', pipelineId: pipe.id }, `${pipe.title} (échecs)`)
499 }
500 >
501 <Text color={STATUS_HEX.failed}>{running[`pipe-${pipe.key}`] ? 'Relance…' : '↻ Relancer les échecs'}</Text>
502 </Button>
503 )}
504 {pipe.url && <Link href={pipe.url} label="Ouvrir ↗" />}
505 <Text dimColor>
506 {formatAge(pipe.createdAt, now)}
507 {backend ? ` · via ${backend}` : ''}
508 </Text>
509 </Box>
510 </Box>
511 )}
512 </Box>
513 )
514 })
515
516 const tree = (
517 <Box flexDirection="column" gap={1}>
518 {error && (
519 <Text color={STATUS_HEX.failed} wrap="truncate">
520 CI : {error}
521 </Text>
522 )}
523 {rows}
524 </Box>
525 )
526 // what the engine and other plugins draw here (plan-progress' bars) stays, under ours
527 const below = await next(e)
528
529 return below ? (
530 <Box flexDirection="column" gap={1}>
531 {tree}
532 {below}
533 </Box>
534 ) : (
535 tree
536 )
537 })
538}
539hooks/track.ts 219 lines1// The pipeline's progress rail: one segment per stage, fluid width, a light
2// sweeping over the stage at work. Loosely after the plan-progress mod's palette.
3import type { Status } from '../types'
4import type { Stage } from './providers'
5
6export const STATUS_HEX: Record<Status, string> = {
7 running: '#7858CA',
8 pending: '#7858CA',
9 manual: '#AD6400',
10 failed: '#C5353E',
11 success: '#18883A',
12 canceled: '#84828A',
13 skipped: '#84828A',
14 unknown: '#84828A',
15}
16
17const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
18
19export type Progress = {
20 /** Share of jobs settled, 0..1 (1 only once the pipeline ended). */
21 frac: number
22 /** 0..99 while unfinished, 100 once ended. */
23 percent: number
24 /** The stage the pipeline is at: the first not yet passed, else the last. */
25 current: Stage | undefined
26 /** Stage boundaries as shares of the rail, 0..1. */
27 bounds: number[]
28}
29
30const PASSED: readonly Status[] = ['success', 'skipped']
31const SETTLED: readonly Status[] = ['success', 'failed', 'canceled', 'skipped']
32
33export function progressOf(stages: readonly Stage[], pipelineStatus: Status): Progress {
34 const total = stages.reduce((n, stage) => n + stage.jobs.length, 0)
35 const settled = stages.reduce((n, stage) => n + stage.done, 0)
36 const isOver = SETTLED.includes(pipelineStatus)
37 const frac = total === 0 ? (isOver ? 1 : 0) : isOver ? 1 : Math.min(1, settled / total)
38 const percent = isOver ? 100 : Math.min(99, Math.floor(frac * 100))
39 const current = stages.find(stage => !PASSED.includes(stage.status)) ?? stages.at(-1)
40 const bounds: number[] = []
41 let acc = 0
42 stages.forEach((stage, i) => {
43 acc += stage.jobs.length
44 if (i < stages.length - 1 && total > 0) bounds.push(acc / total)
45 })
46
47 return { frac, percent, current, bounds }
48}
49
50export const RAIL_H = 10
51
52/** The share of a stage's jobs that settled, for the running stage's partial fill. */
53const stageShare = (stage: Stage) => (stage.jobs.length === 0 ? 0 : stage.done / stage.jobs.length)
54
55/**
56 * A fluid rail, `width="100%"` with every coordinate in percent, so it fills
57 * whatever box it is given and keeps px-exact rounding and height: one segment
58 * per stage (sized by its jobs), filled in its status colour, the running one
59 * partly filled with a light sweeping across it, hover titles on each.
60 */
61export function railSvg(stages: readonly Stage[], status: Status): string {
62 const H = RAIL_H
63 const total = stages.reduce((n, stage) => n + Math.max(1, stage.jobs.length), 0)
64 const GAP = stages.length > 1 ? 0.8 : 0
65 const room = 100 - GAP * (stages.length - 1)
66 const pct = (n: number) => `${n.toFixed(3)}%`
67 const segs: string[] = []
68 const clips: string[] = []
69 let x = 0
70
71 const list = stages.length > 0 ? stages : [{ name: '', status, jobs: [], done: 0 } as Stage]
72 list.forEach((stage, i) => {
73 const w = stages.length > 0 ? (Math.max(1, stage.jobs.length) / total) * room : 100
74 const color = STATUS_HEX[stage.status]
75 const isLive = stage.status === 'running' || stage.status === 'pending'
76 const fill = stage.status === 'success' || stage.status === 'failed' || stage.status === 'manual' ? 1 : isLive ? stageShare(stage) : 0
77 const title = stage.name ? `<title>${esc(stage.name)} · ${stage.done}/${stage.jobs.length}</title>` : ''
78 clips.push(`<clipPath id="c${i}"><rect x="${pct(x)}" y="0" width="${pct(w)}" height="${H}" rx="${H / 2}"/></clipPath>`)
79 segs.push(`<g clip-path="url(#c${i})">${title}`)
80 segs.push(`<rect x="${pct(x)}" y="0" width="${pct(w)}" height="${H}" class="trk"/>`)
81 if (fill > 0) segs.push(`<rect x="${pct(x)}" y="0" width="${pct(w * fill)}" height="${H}" fill="${color}"/>`)
82 if (isLive) {
83 // a soft light sweeping across the running stage, then a pulse on its tint
84 segs.push(`<rect x="${pct(x)}" y="0" width="${pct(w)}" height="${H}" fill="${color}" opacity=".22"><animate attributeName="opacity" values=".12;.34;.12" dur="1.8s" repeatCount="indefinite"/></rect>`)
85 segs.push(
86 `<rect x="${pct(x - w * 0.4)}" y="0" width="${pct(w * 0.4)}" height="${H}" fill="url(#sweep)"><animate attributeName="x" from="${pct(x - w * 0.4)}" to="${pct(x + w)}" dur="1.6s" repeatCount="indefinite"/></rect>`,
87 )
88 }
89 segs.push('</g>')
90 x += w + GAP
91 })
92
93 return `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${H}" preserveAspectRatio="none">
94<style>.trk{fill:#808080;fill-opacity:.2}svg{display:block}:root,html,body{background:transparent!important;margin:0;overflow:hidden}</style>
95<defs>${clips.join('')}<linearGradient id="sweep" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs>
96${segs.join('')}</svg>`
97}
98
99/** The terminal's rail: a heavy line per stage in its colour, light where it is still to run. */
100export function textRail(stages: readonly Stage[], cells: number): { text: string; status: Status | 'todo' }[] {
101 if (stages.length === 0) return [{ text: '─'.repeat(cells), status: 'todo' }]
102 const total = stages.reduce((n, stage) => n + Math.max(1, stage.jobs.length), 0)
103 const room = Math.max(stages.length, cells - (stages.length - 1))
104 const out: { text: string; status: Status | 'todo' }[] = []
105 stages.forEach((stage, i) => {
106 const w = Math.max(1, Math.round((Math.max(1, stage.jobs.length) / total) * room))
107 const isLive = stage.status === 'running' || stage.status === 'pending'
108 const done = stage.status === 'success' || stage.status === 'failed' || stage.status === 'manual' ? w : isLive ? Math.round(w * (stage.done / Math.max(1, stage.jobs.length))) : 0
109 if (i > 0) out.push({ text: ' ', status: 'todo' })
110 if (done > 0) out.push({ text: '━'.repeat(done), status: stage.status })
111 if (w - done > 0) out.push({ text: '─'.repeat(w - done), status: isLive ? stage.status : 'todo' })
112 })
113
114 return out
115}
116
117export const STEPPER_H = 20
118
119const NODE_ICON: Partial<Record<Status, string>> = {
120 success: 'M-3.2 0.2 -1 2.4 3.4-2.2',
121 failed: 'M-2.6-2.6 2.6 2.6M2.6-2.6-2.6 2.6',
122 canceled: 'M-2.8 0h5.6',
123 skipped: 'M-1.4-2.8 1.6 0-1.4 2.8',
124 manual: 'M-1.6-2.8 2.6 0-1.6 2.8z',
125}
126
127/**
128 * The CI pipeline graph, the way GitHub and GitLab draw it in small: one node
129 * per stage, evenly spread over the full width, joined by a line that takes
130 * the colour of the stages already passed. A settled node carries its icon,
131 * the running one spins an arc and breathes a halo, a waiting one is hollow.
132 * Nodes sit in nested SVGs placed in percent, so the graph is fluid while each
133 * node keeps its pixel size. Hovering a node names the stage.
134 */
135export function stepperSvg(stages: readonly Stage[], status: Status): string {
136 const H = STEPPER_H
137 const cy = H / 2
138 const R = 7
139 const list = stages.length > 0 ? stages : [{ name: '', status, jobs: [], done: 0 } as Stage]
140 const n = list.length
141 // nodes spread edge to edge, each nudged inwards by up to its radius in px so the
142 // end ones stay whole at any width: percent for the place, px for the nudge
143 const share = (i: number) => (n === 1 ? 0.5 : i / (n - 1))
144 const at = (i: number) => share(i) * 100
145 const nudge = (i: number) => (R + 1) * (1 - 2 * share(i))
146 const parts: string[] = []
147
148 // links first, under the nodes: each in a frame from one node's place to the next
149 for (let i = 0; i < n - 1; i++) {
150 const from = list[i]
151 const isPassed = from?.status === 'success' || from?.status === 'skipped'
152 const color = isPassed ? STATUS_HEX.success : from?.status === 'failed' ? STATUS_HEX.failed : '#808080'
153 const opacity = isPassed || from?.status === 'failed' ? 0.9 : 0.35
154 const frame = `<svg x="${at(i).toFixed(3)}%" y="0" width="${(at(i + 1) - at(i)).toFixed(3)}%" height="${H}" overflow="visible">`
155 const ends = `x1="${nudge(i).toFixed(1)}" y1="${cy}" x2="100%" y2="${cy}"`
156 parts.push(`${frame}<line ${ends} stroke="${color}" stroke-opacity="${opacity}" stroke-width="2"/>`)
157 if (from?.status === 'running') {
158 // work flowing towards the next stage
159 parts.push(
160 `<line ${ends} stroke="${STATUS_HEX.running}" stroke-width="2" stroke-dasharray="4 6"><animate attributeName="stroke-dashoffset" from="20" to="0" dur=".9s" repeatCount="indefinite"/></line>`,
161 )
162 }
163 parts.push('</svg>')
164 }
165
166 list.forEach((stage, i) => {
167 const color = STATUS_HEX[stage.status]
168 const title = stage.name ? `<title>${esc(stage.name)} · ${stage.done}/${stage.jobs.length} · ${stage.status}</title>` : ''
169 let node = ''
170 if (stage.status === 'running') {
171 node =
172 `<circle r="${R + 2}" fill="${color}" opacity=".25"><animate attributeName="r" values="${R};${R + 4};${R}" dur="1.6s" repeatCount="indefinite"/><animate attributeName="opacity" values=".35;0;.35" dur="1.6s" repeatCount="indefinite"/></circle>` +
173 `<circle r="${R}" class="bg" stroke="${color}" stroke-width="2"/>` +
174 `<path d="M0-${R - 3.5}A${R - 3.5} ${R - 3.5} 0 0 1 ${R - 3.5} 0" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="1s" repeatCount="indefinite"/></path>`
175 } else if (stage.status === 'pending' || stage.status === 'unknown') {
176 node = `<circle r="${R - 1}" class="bg" stroke="#808080" stroke-opacity=".7" stroke-width="2"/>`
177 } else {
178 const icon = NODE_ICON[stage.status]
179 node =
180 `<circle r="${R}" fill="${color}"/>` +
181 (icon ? `<path d="${icon}" fill="${stage.status === 'manual' ? '#fff' : 'none'}" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>` : '')
182 }
183 parts.push(`<svg x="${at(i).toFixed(3)}%" y="0" width="1" height="${H}" overflow="visible"><g transform="translate(${nudge(i).toFixed(1)} ${cy})">${title}${node}</g></svg>`)
184 })
185
186 return `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${H}" overflow="visible">
187<style>svg{display:block;overflow:visible}:root,html,body{background:transparent!important;margin:0;overflow:hidden;color-scheme:light dark}.bg{fill:#fff}@media (prefers-color-scheme:dark){.bg{fill:#1F1E1D}}</style>
188${parts.join('')}</svg>`
189}
190
191const TEXT_NODE: Record<Status, string> = {
192 success: '●',
193 failed: '✕',
194 running: '◉',
195 pending: '○',
196 manual: '▶',
197 canceled: '⊘',
198 skipped: '◌',
199 unknown: '○',
200}
201
202/** The terminal's graph: `●━━━━◉────○`, nodes in their colour, passed links heavy. */
203export function textStepper(stages: readonly Stage[], cells: number): { text: string; status: Status | 'todo' }[] {
204 const list = stages.length > 0 ? stages : []
205 if (list.length === 0) return [{ text: '○', status: 'todo' }]
206 const n = list.length
207 const link = n > 1 ? Math.max(1, Math.floor((cells - n) / (n - 1))) : 0
208 const out: { text: string; status: Status | 'todo' }[] = []
209 list.forEach((stage, i) => {
210 out.push({ text: TEXT_NODE[stage.status], status: stage.status === 'pending' ? 'todo' : stage.status })
211 if (i < n - 1) {
212 const isPassed = stage.status === 'success' || stage.status === 'skipped'
213 out.push(isPassed ? { text: '━'.repeat(link), status: 'success' } : stage.status === 'failed' ? { text: '━'.repeat(link), status: 'failed' } : { text: '─'.repeat(link), status: 'todo' })
214 }
215 })
216
217 return out
218}
219hooks/gitlab.ts 112 lines1import type { McpToolResult } from 'claude-code'
2
3import type { Repo } from '../types'
4import { jobsArgv, listArgv, retryFailedArgv, retryJobArgv } from './providers'
5
6/** One thing the pane asks of GitLab. */
7export type GitlabOp =
8 | { op: 'list'; branch: string; perPage: number }
9 | { op: 'jobs'; pipelineId: string }
10 | { op: 'retryJob'; pipelineId: string; jobId: string }
11 | { op: 'retryFailed'; pipelineId: string }
12
13export type BackendKind = 'mcp-official' | 'mcp-local' | 'glab'
14
15export const BACKEND_LABEL: Record<BackendKind, string> = {
16 'mcp-official': 'MCP GitLab officiel',
17 'mcp-local': 'MCP GitLab local',
18 glab: 'glab',
19}
20
21/** The MCP servers to try, in order, for each kind. */
22export type GitlabServers = { official: string[]; local: string[] }
23
24type McpCall = { tool: string; args: Record<string, unknown> }
25
26/**
27 * The official GitLab MCP server (`/api/v4/mcp`): projects by `id` (path or
28 * number), pipeline ids as integers; it retries a pipeline's failed jobs
29 * (`save_pipeline`) but has no single-job retry.
30 */
31export function officialCall(repo: Repo, op: GitlabOp): McpCall | undefined {
32 switch (op.op) {
33 case 'list':
34 return { tool: 'list_pipelines', args: { id: repo.path, ref: op.branch, per_page: op.perPage } }
35 case 'jobs':
36 return { tool: 'get_pipeline_jobs', args: { id: repo.path, pipeline_id: Number(op.pipelineId), per_page: 100 } }
37 case 'retryJob':
38 return undefined
39 case 'retryFailed':
40 return { tool: 'save_pipeline', args: { project_id: repo.path, pipeline_id: Number(op.pipelineId), action: 'retry' } }
41 }
42}
43
44/** A local GitLab MCP server (gitlab-mcp family): numeric project ids, string pipeline / job ids. */
45export function localCall(projectId: string, op: GitlabOp): McpCall {
46 switch (op.op) {
47 case 'list':
48 return { tool: 'list_pipelines', args: { project_id: projectId, ref: op.branch, per_page: op.perPage } }
49 case 'jobs':
50 return { tool: 'list_pipeline_jobs', args: { project_id: projectId, pipeline_id: op.pipelineId, per_page: 100 } }
51 case 'retryJob':
52 return { tool: 'retry_pipeline_job', args: { project_id: projectId, job_id: op.jobId } }
53 case 'retryFailed':
54 return { tool: 'retry_pipeline', args: { project_id: projectId, pipeline_id: op.pipelineId } }
55 }
56}
57
58export function glabArgv(repo: Repo, op: GitlabOp): string[] {
59 switch (op.op) {
60 case 'list':
61 return listArgv(repo, op.branch)
62 case 'jobs':
63 return jobsArgv(repo, op.pipelineId)
64 case 'retryJob':
65 return retryJobArgv(repo, op.pipelineId, op.jobId)
66 case 'retryFailed':
67 return retryFailedArgv(repo, op.pipelineId)
68 }
69}
70
71function mcpText(result: McpToolResult): string {
72 return result.content
73 .map(block => (block.type === 'text' ? (block as { text?: string }).text ?? '' : ''))
74 .join('\n')
75 .trim()
76}
77
78/** What an MCP tool answered, as data; throws with the server's words when it failed. */
79export function mcpData(result: McpToolResult): unknown {
80 const text = mcpText(result)
81 if (result.isError) throw new Error(text.split('\n')[0] || 'MCP error')
82 if (result.structuredContent !== undefined) return result.structuredContent
83 try {
84 return JSON.parse(text || 'null')
85 } catch {
86 throw new Error(text.split('\n')[0] || 'unexpected MCP answer')
87 }
88}
89
90/** Same server, whatever the spelling (`plugin:gitlab:gitlab`, `plugin_gitlab_gitlab`). */
91export function serverKey(name: string): string {
92 return name.replace(/[^A-Za-z0-9]/g, '_').toLowerCase()
93}
94
95/**
96 * The GitLab MCP servers of this session: the configured ones first, then
97 * any other server whose tools give it away (`save_pipeline` is the official
98 * server's; `retry_pipeline_job` a local gitlab-mcp's).
99 */
100export function detectServers(configured: GitlabServers, toolNames: readonly string[]): GitlabServers {
101 const out: GitlabServers = { official: [...configured.official], local: [...configured.local] }
102 const known = new Set([...out.official, ...out.local].map(serverKey))
103 for (const name of toolNames) {
104 const match = /^mcp__(.+)__(save_pipeline|retry_pipeline_job)$/.exec(name)
105 if (!match?.[1] || known.has(serverKey(match[1]))) continue
106 known.add(serverKey(match[1]))
107 out[match[2] === 'save_pipeline' ? 'official' : 'local'].push(match[1])
108 }
109
110 return out
111}
112hooks/providers.ts 360 lines1import type { Job, Pipeline, Repo, Status } from '../types'
2
3const RUN_LIMIT = 5
4
5/** Reads a git remote URL (ssh, scp-like or https) into the repository it names. */
6export function parseRemote(url: string): Repo | undefined {
7 const raw = url.trim()
8 let host: string | undefined
9 let path: string | undefined
10 const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/.exec(raw)
11 if (scp && !/^[a-z]+:\/\//i.test(raw)) {
12 host = scp[1]
13 path = scp[2]
14 } else {
15 const web = /^[a-z+]+:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(raw)
16 if (web) {
17 host = web[1]
18 path = web[2]
19 }
20 }
21 if (!host || !path) return undefined
22 path = path.replace(/\.git$/, '').replace(/\/+$/, '')
23 if (!path.includes('/')) return undefined
24 const provider = host === 'github.com' || host.includes('github') ? 'github' : 'gitlab'
25
26 return { provider, host, path }
27}
28
29/**
30 * Says whether a shell command pushes a branch (or opens a PR / MR for one),
31 * and which branch when the command names it.
32 */
33export function pushedBranch(command: string): { isPush: boolean; branch?: string } {
34 const parts = command.split(/&&|\|\||;|\|/)
35 for (const part of parts) {
36 const words = part.trim().split(/\s+/)
37 const at = words.findIndex((word, i) => word === 'git' && words[i + 1] === 'push')
38 if (at >= 0) {
39 const rest = words.slice(at + 2)
40 if (rest.some(word => ['--delete', '-d', '--tags', '--mirror'].includes(word))) continue
41 const positional = rest.filter(word => !word.startsWith('-'))
42 const spec = positional[1]
43 if (!spec) return { isPush: true }
44 const dst = (spec.includes(':') ? spec.split(':')[1] : spec)?.replace(/^\+/, '')
45 const branch = dst?.replace(/^refs\/heads\//, '')
46 if (!branch || branch === 'HEAD' || branch.startsWith('refs/')) return { isPush: true }
47
48 return { isPush: true, branch }
49 }
50 if (/\bgh\s+pr\s+create\b/.test(part) || /\bglab\s+mr\s+create\b/.test(part)) {
51 return { isPush: true }
52 }
53 }
54
55 return { isPush: false }
56}
57
58function ghRepo(repo: Repo): string {
59 return repo.host === 'github.com' ? repo.path : `${repo.host}/${repo.path}`
60}
61
62function glabProject(repo: Repo): string {
63 return `projects/${encodeURIComponent(repo.path)}`
64}
65
66/** The argv listing a branch's latest pipelines (without the binary). */
67export function listArgv(repo: Repo, branch: string): string[] {
68 if (repo.provider === 'github') {
69 return [
70 'run', 'list', '-R', ghRepo(repo), '--branch', branch, '--limit', String(RUN_LIMIT),
71 '--json', 'databaseId,displayTitle,workflowName,status,conclusion,headBranch,createdAt,url,event',
72 ]
73 }
74
75 return [
76 'api', '--hostname', repo.host,
77 `${glabProject(repo)}/pipelines?ref=${encodeURIComponent(branch)}&per_page=${RUN_LIMIT}`,
78 ]
79}
80
81/** The argv listing one pipeline's jobs. */
82export function jobsArgv(repo: Repo, pipelineId: string): string[] {
83 if (repo.provider === 'github') {
84 return ['run', 'view', pipelineId, '-R', ghRepo(repo), '--json', 'jobs']
85 }
86
87 return ['api', '--hostname', repo.host, `${glabProject(repo)}/pipelines/${pipelineId}/jobs?per_page=100`]
88}
89
90/** The argv retrying one job. */
91export function retryJobArgv(repo: Repo, pipelineId: string, jobId: string): string[] {
92 if (repo.provider === 'github') {
93 return ['run', 'rerun', pipelineId, '--job', jobId, '-R', ghRepo(repo)]
94 }
95
96 return ['api', '--hostname', repo.host, '-X', 'POST', `${glabProject(repo)}/jobs/${jobId}/retry`]
97}
98
99/** The argv retrying every failed job of a pipeline. */
100export function retryFailedArgv(repo: Repo, pipelineId: string): string[] {
101 if (repo.provider === 'github') {
102 return ['run', 'rerun', pipelineId, '--failed', '-R', ghRepo(repo)]
103 }
104
105 return ['api', '--hostname', repo.host, '-X', 'POST', `${glabProject(repo)}/pipelines/${pipelineId}/retry`]
106}
107
108export function ghStatus(status: string, conclusion: string | null | undefined): Status {
109 if (status === 'completed') {
110 switch (conclusion) {
111 case 'success': return 'success'
112 case 'failure':
113 case 'timed_out':
114 case 'startup_failure': return 'failed'
115 case 'cancelled': return 'canceled'
116 case 'skipped':
117 case 'neutral': return 'skipped'
118 case 'action_required': return 'manual'
119 default: return 'unknown'
120 }
121 }
122 if (status === 'in_progress') return 'running'
123 if (['queued', 'waiting', 'pending', 'requested'].includes(status)) return 'pending'
124
125 return 'unknown'
126}
127
128export function glStatus(status: string): Status {
129 switch (status) {
130 case 'success': return 'success'
131 case 'failed': return 'failed'
132 case 'running': return 'running'
133 case 'created':
134 case 'pending':
135 case 'preparing':
136 case 'scheduled':
137 case 'waiting_for_resource':
138 case 'waiting_for_callback': return 'pending'
139 case 'canceled':
140 case 'canceling': return 'canceled'
141 case 'skipped': return 'skipped'
142 case 'manual': return 'manual'
143 default: return 'unknown'
144 }
145}
146
147type GhRun = {
148 databaseId: number
149 displayTitle?: string
150 workflowName?: string
151 status: string
152 conclusion?: string | null
153 headBranch?: string
154 createdAt?: string
155 url?: string
156}
157
158/** Turns `listArgv`'s output into pipelines. Throws on unreadable output. */
159export function parsePipelines(repo: Repo, stdout: string, branch: string): Pipeline[] {
160 const data: unknown = JSON.parse(stdout || '[]')
161 if (repo.provider === 'github') {
162 if (!Array.isArray(data)) throw new Error(errorText(data) ?? 'unexpected answer')
163
164 return (data as GhRun[]).map(run => ({
165 key: `github-${run.databaseId}`,
166 id: String(run.databaseId),
167 branch: run.headBranch ?? branch,
168 title: [run.workflowName, run.displayTitle].filter(Boolean).join(' · ') || `#${run.databaseId}`,
169 status: ghStatus(run.status, run.conclusion),
170 url: run.url,
171 createdAt: run.createdAt,
172 }))
173 }
174
175 return glPipelines(data, branch)
176}
177
178type GhJob = {
179 databaseId: number
180 name: string
181 status: string
182 conclusion?: string | null
183 startedAt?: string
184 completedAt?: string
185 url?: string
186}
187
188function seconds(from?: string, to?: string): number | undefined {
189 if (!from || !to || to.startsWith('0001')) return undefined
190 const ms = Date.parse(to) - Date.parse(from)
191
192 return Number.isFinite(ms) && ms >= 0 ? Math.round(ms / 1000) : undefined
193}
194
195/** Turns `jobsArgv`'s output into jobs. Throws on unreadable output. */
196export function parseJobs(repo: Repo, stdout: string): Job[] {
197 const data: unknown = JSON.parse(stdout || '[]')
198 if (repo.provider === 'github') {
199 const jobs = (data as { jobs?: GhJob[] }).jobs
200 if (!Array.isArray(jobs)) throw new Error(errorText(data) ?? 'unexpected answer')
201
202 return jobs.map(job => ({
203 id: String(job.databaseId),
204 name: job.name,
205 status: ghStatus(job.status, job.conclusion),
206 durationSec: seconds(job.startedAt, job.completedAt),
207 url: job.url,
208 }))
209 }
210
211 return glJobs(data)
212}
213
214type Rec = Record<string, unknown>
215
216const str = (value: unknown): string | undefined =>
217 typeof value === 'string' ? value : typeof value === 'number' ? String(value) : undefined
218
219/** `123`, `"123"` or a GraphQL global id (`gid://gitlab/Ci::Pipeline/123`) to `"123"`. */
220const plainId = (value: unknown): string => str(value)?.replace(/^gid:\/\/.*\//, '') ?? ''
221
222/**
223 * The list a GitLab answer carries: a REST array, or a GraphQL-ish object
224 * (`{ pipelines: { nodes } }`, `{ jobs: [...] }`, `{ nodes }`, `{ data: ... }`).
225 */
226export function glList(data: unknown, keys: readonly string[]): Rec[] {
227 if (Array.isArray(data)) return data as Rec[]
228 if (!data || typeof data !== 'object') throw new Error('unexpected answer')
229 const obj = data as Rec
230 if (Array.isArray(obj.nodes)) return obj.nodes as Rec[]
231 for (const key of [...keys, 'data', 'project', 'pipeline']) {
232 if (key in obj && obj[key] !== null && typeof obj[key] === 'object') {
233 try {
234 return glList(obj[key], keys)
235 } catch {
236 // try the next key
237 }
238 }
239 }
240 throw new Error(errorText(data) ?? 'unexpected answer')
241}
242
243/** GitLab pipelines from any answer shape (REST via glab or MCP, GraphQL-ish via MCP). */
244export function glPipelines(data: unknown, branch: string): Pipeline[] {
245 return glList(data, ['pipelines']).map(pipe => {
246 const id = plainId(pipe.iid !== undefined && pipe.id === undefined ? pipe.iid : pipe.id)
247 const source = str(pipe.source)?.toLowerCase()
248
249 return {
250 key: `gitlab-${id}`,
251 id,
252 branch: str(pipe.ref) ?? branch,
253 title: `#${id}${source ? ` · ${source}` : ''}`,
254 status: glStatus(str(pipe.status)?.toLowerCase() ?? ''),
255 url: str(pipe.web_url) ?? str(pipe.webUrl),
256 createdAt: str(pipe.created_at) ?? str(pipe.createdAt),
257 }
258 })
259}
260
261/** GitLab jobs from any answer shape, oldest stage first. */
262export function glJobs(data: unknown): Job[] {
263 const list = glList(data, ['jobs']).map(job => {
264 const stage = job.stage && typeof job.stage === 'object' ? str((job.stage as Rec).name) : str(job.stage)
265 const duration = typeof job.duration === 'number' ? Math.round(job.duration) : undefined
266
267 return {
268 id: plainId(job.id),
269 name: str(job.name) ?? '?',
270 stage,
271 status: glStatus(str(job.status)?.toLowerCase() ?? ''),
272 durationSec: duration,
273 url: str(job.web_url) ?? str(job.webUrl),
274 }
275 })
276 const isNewestFirst = list.length > 1 && Number(list[0]?.id) > Number(list[list.length - 1]?.id)
277
278 return isNewestFirst ? list.reverse() : list
279}
280
281function errorText(data: unknown): string | undefined {
282 if (data && typeof data === 'object' && 'message' in data) return String((data as { message: unknown }).message)
283
284 return undefined
285}
286
287export function isActive(status: Status): boolean {
288 return status === 'running' || status === 'pending'
289}
290
291export function formatDuration(sec?: number): string {
292 if (sec === undefined) return ''
293 if (sec < 60) return `${sec}s`
294 const m = Math.floor(sec / 60)
295
296 return m < 60 ? `${m}m${String(sec % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
297}
298
299export function formatAge(iso: string | undefined, now: number): string {
300 if (!iso) return ''
301 const sec = Math.max(0, Math.round((now - Date.parse(iso)) / 1000))
302 if (!Number.isFinite(sec)) return ''
303 if (sec < 60) return `${sec}s`
304 if (sec < 3600) return `${Math.floor(sec / 60)}m`
305 if (sec < 86400) return `${Math.floor(sec / 3600)}h`
306
307 return `${Math.floor(sec / 86400)}j`
308}
309
310export type Stage = { name: string; status: Status; jobs: Job[]; done: number }
311
312const SETTLED: readonly Status[] = ['success', 'failed', 'canceled', 'skipped']
313
314/** The status of a group of jobs: a failure wins, then work in flight, then what is left. */
315export function rollup(statuses: readonly Status[]): Status {
316 if (statuses.length === 0) return 'unknown'
317 if (statuses.includes('failed')) return 'failed'
318 if (statuses.includes('running')) return 'running'
319 if (statuses.some(status => status === 'pending')) {
320 return statuses.some(status => SETTLED.includes(status)) ? 'running' : 'pending'
321 }
322 if (statuses.includes('manual')) return 'manual'
323 if (statuses.every(status => status === 'canceled')) return 'canceled'
324 if (statuses.every(status => status === 'skipped')) return 'skipped'
325
326 return statuses.includes('canceled') ? 'canceled' : 'success'
327}
328
329/** Jobs grouped by stage in run order (a GitHub job is its own stage). */
330export function stagesOf(jobs: readonly Job[]): Stage[] {
331 const order: string[] = []
332 const groups = new Map<string, Job[]>()
333 for (const job of jobs) {
334 const name = job.stage ?? job.name
335 if (!groups.has(name)) {
336 groups.set(name, [])
337 order.push(name)
338 }
339 groups.get(name)?.push(job)
340 }
341
342 return order.map(name => {
343 const list = groups.get(name) ?? []
344
345 return {
346 name,
347 jobs: list,
348 status: rollup(list.map(job => job.status)),
349 done: list.filter(job => SETTLED.includes(job.status)).length,
350 }
351 })
352}
353
354/** The latest pipeline of each branch, in the order the list holds them. */
355export function latestPerBranch(list: readonly Pipeline[]): Pipeline[] {
356 const seen = new Set<string>()
357
358 return list.filter(pipe => (seen.has(pipe.branch) ? false : (seen.add(pipe.branch), true)))
359}
360types/index.d.ts 49 lines1export type Provider = 'github' | 'gitlab'
2
3export type Repo = { provider: Provider; host: string; path: string }
4
5export type Status =
6 | 'success'
7 | 'failed'
8 | 'running'
9 | 'pending'
10 | 'canceled'
11 | 'skipped'
12 | 'manual'
13 | 'unknown'
14
15export type Job = {
16 id: string
17 name: string
18 stage?: string
19 status: Status
20 durationSec?: number
21 url?: string
22}
23
24export type Pipeline = {
25 key: string
26 id: string
27 branch: string
28 title: string
29 status: Status
30 url?: string
31 createdAt?: string
32 jobs?: Job[]
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'ci-jobs': {
38 repo: Repo | null
39 branches: string[]
40 pipelines: Pipeline[]
41 expanded: Record<string, boolean>
42 busy: Record<string, boolean>
43 error: string | null
44 updatedAt: number
45 via: string | null
46 }
47 }
48}
49