A panel that groups every open merge request or pull request by whether it can merge, shows failing jobs, and can retry or merge after you confirm. Refreshed…

A Claude Code mod that opens a panel beside the conversation listing every open merge request in the current repository (or pull request, on GitHub), grouped by whether it can merge, with its failing jobs one click away. It refreshes once a minute.
Merge Watch · platform/meridian ↻ ⤢ ✕
2 ready 3 failing 1 draft
READY TO MERGE 2 ─────────────────────────────────────────────
!1030 add ADR-1000 design-system buttons an… ━━━━━━━━━━ ▸
✓ needs review · 1 allowed failure
PIPELINE FAILING 3 ───────────────────────────────────────────
!1007 update dependency @ai-sdk/amazon-bed… ━━━━━━━━━━ ▾
✕ typecheck
fix(deps): update dependency @ai-sdk/amazon-bedrock to v5
Status Open · No approval required
Branch renovate/ai-sdk-amazon-bedrock-5.x → main · renovate
Pipeline #1470130 · 35 passed · 2 failed · 8 manual
╭───────────────────────────────────────────╮
│ typecheck failed │
│ dependency-audit allowed │
╰───────────────────────────────────────────╯
[ Retry failed ] ( Open MR ) [ Copy link ]
6 of 12 open · updated 14:25:35 · show all
feat(scope): prefix, and a reason line (failed job names, or "needs review · 1 allowed failure"). Allowed failures never count as failures. The small bar shows the pipeline's mix: passed, failed, allowed to fail, manual.Refreshing calls the GitLab CLI or the GitHub API only. It never starts a Claude turn and uses no model tokens. The only changes it ever makes are Retry failed and Merge, and only after you confirm.
On the desktop app the summary and pipeline bars are drawn as graphics; in the terminal they are coloured block characters. Fonts and sizes follow each app, since a mod cannot set them.
Merge Watch is installed per project. From the root of the project:
claude plugin marketplace add b00y0h/merge-watch --scope project
claude plugin install merge-watch@merge-watch --scope project
--scope project writes the marketplace and the enabled plugin to the project's .claude/settings.json. Once that file is committed, collaborators are prompted to install the marketplace when they trust the project, but each collaborator still installs it themselves.
To use it only for yourself in one repository, without touching the shared settings, use local scope instead (written to .claude/settings.local.json):
claude plugin marketplace add b00y0h/merge-watch --scope local
claude plugin install merge-watch@merge-watch --scope local
Restart Claude Code, or run /reload-plugins, to load it. Merge Watch does nothing until you run /merge-watch: no panel opens and nothing is fetched when a session starts.
GitLab (gitlab.com and self-hosted): Merge Watch uses the GitLab CLI, glab, and reuses its sign-in. Install glab, then in your own terminal:
glab auth login --hostname gitlab.example.com
Merge Watch passes --hostname on every call, so it always asks the host your remote points at.
GitHub (github.com and GitHub Enterprise): Merge Watch calls the GitHub REST API directly and needs a token with read access to pull requests, checks, actions and commit statuses (and write access to actions and contents if you want Retry failed and Merge to work). Either:
github_token option with /plugin configure merge-watch@merge-watch (stored in your system's secure storage, not in settings files), orGITHUB_TOKEN (or GH_TOKEN) set in its environment.Never paste a token into the chat or a project file. A GitLab sign-in cannot be used for GitHub.
| Command | What it does |
|---|---|
/merge-watch | Start watching this repository and open the panel. Run it again to bring the panel back |
/merge-watch refresh | Refresh now. A refresh already running is joined, not repeated |
/merge-watch hide | Close the panel, keep refreshing in the background |
/merge-watch off | Close the panel and stop refreshing for this repository |
/merge-watch on | Same as /merge-watch: start (or resume), refresh at once and open the panel |
/merge-watch repo | Show which repository is watched and pick another remote |
/merge-watch repo <remote> | Watch the remote with that name, e.g. upstream |
The panel's Hide button works like /merge-watch hide. All commands work while Claude is busy.
Merge Watch finds the Git repository from the session's working directory, including nested folders and Git worktrees, and reads its remotes (HTTPS, SSH and git@host:group/sub/project forms, nested GitLab groups included). It uses, in order: the remote you picked before, then origin, then the only supported remote. If several remotes are equally plausible it shows a picker instead of guessing.
github.com and gitlab.com are recognised, as is any host with gitlab. in its name. Add other hosts in the plugin options: github_hosts for GitHub Enterprise, gitlab_hosts for self-hosted GitLab.
Preferences are stored per repository, keyed by provider, host and full project path, so two projects with the same name on different hosts never share settings. The remote you picked is stored per checkout and survives restarts.
gitlab_hosts or github_hosts with /plugin configure merge-watch@merge-watch.glab auth login --hostname <host> for GitLab, or set a GitHub token as above. Then run /merge-watch refresh./merge-watch in each session where you want it.claude -p answers that Merge Watch needs an app A headless run has nowhere to show the panel, so Merge Watch does not start there.To see why a mod did nothing, start Claude Code with claude --debug and look for lines that mention merge-watch.
claude plugin validate .
claude plugin test .
The tests mock git, glab, the GitHub API, the clock and storage, so they need no credentials or network.
hooks/register.tsx 9 lines1import type { Register } from 'claude-code'
2
3import { registerMergeWatch } from './merge-watch'
4
5// One entry point, so more mods can be registered beside Merge Watch later.
6export const register: Register = (on, options) => {
7 registerMergeWatch(on, options)
8}
9hooks/merge-watch.tsx 1217 lines1// Merge Watch: the pane, its commands, and the once-a-minute refresh.
2// Fetching lives in the adapters (gitlab.ts, github.ts); this file schedules, merges and draws.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On, PluginOptions, RenderElement, Timer } from 'claude-code'
6
7import type {
8 MergeWatchPipeline,
9 MergeWatchRepo,
10 MergeWatchRequest,
11 MergeWatchSnapshot,
12 MergeWatchView,
13} from '../types'
14import { fetchGitHub, mergeGitHub, retryFailedGitHub } from './github'
15import type { HttpResponse, HttpSend } from './github'
16import { fetchGitLab, mergeGitLab, retryFailedGitLab } from './gitlab'
17import type { GlabRunner } from './gitlab'
18import { chooseRepo, reposFromRemotes, splitHosts } from './repo'
19import type { HostConfig } from './repo'
20import { cleanError } from './safe'
21import { AuthError, RateLimitError, jobCounts, sortRequests } from './status'
22import {
23 FAILURES_SHOWN,
24 GROUP_LABEL,
25 GROUP_ORDER,
26 allowedFailures,
27 currentJobs,
28 groupOf,
29 isStale,
30 jobMix,
31 pipelineSummary,
32 realFailures,
33 reasonLine,
34 splitCells,
35 stripPrefix,
36} from './view-model'
37import type { Group, JobMix } from './view-model'
38
39type $ = EngineInterface
40
41// Design tokens. Colours are fixed hex (from the design's oklch values) because both the terminal
42// and the desktop app accept hex; tinted fills always set their own text colour, so they read in
43// light and dark themes alike.
44const C = {
45 muted: '#8a8984',
46 faint: '#9a9893',
47 hairline: '#ebe9e4',
48 green: '#137738',
49 red: '#b6322d',
50 amber: '#9a6500',
51 blue: '#2f6fb3',
52} as const
53
54const TINT: Record<Group, { bg: string; fg: string }> = {
55 ready: { bg: '#d1f2d7', fg: '#005725' },
56 failing: { bg: '#ffdeda', fg: '#9b1f1d' },
57 draft: { bg: '#efeee9', fg: '#5a5955' },
58}
59
60const BAR = {
61 passed: C.green,
62 failed: C.red,
63 allowed: C.amber,
64 manual: '#d6d4ce',
65 other: '#d6d4ce',
66} as const
67
68export const PANE_ID = 'merge-watch'
69export const REFRESH_MS = 60_000
70const HTTP_TIMEOUT_MS = 20_000
71const GLAB_TIMEOUT_MS = 30_000
72const GIT_TIMEOUT_MS = 10_000
73
74const INITIAL_VIEW: MergeWatchView = {
75 phase: 'idle',
76 repo: null,
77 candidates: [],
78 isRefreshing: false,
79 isChoosing: false,
80 notice: null,
81 confirm: null,
82 notices: {},
83}
84
85const viewAtom = atom({ plugin: 'merge-watch', key: 'view' } as const, INITIAL_VIEW)
86const snapshotAtom = atom({ plugin: 'merge-watch', key: 'snapshot' } as const, null)
87const expandedAtom = atom({ plugin: 'merge-watch', key: 'expanded' } as const, {})
88const showAllAtom = atom({ plugin: 'merge-watch', key: 'showAll' } as const, false)
89const moreFailedAtom = atom({ plugin: 'merge-watch', key: 'moreFailed' } as const, {})
90
91// Module state. A hot reload starts it over, and the host drops the old module's timers,
92// so a reload never leaves a second timer running.
93let timer: Timer | undefined
94// True once /merge-watch started monitoring in this session; the timer's ticks do nothing until then.
95let isMonitoring = false
96let generation = 0
97let inflight: Promise<void> | null = null
98let inflightGeneration = -1
99let checkout: { top: string; common: string } | null = null
100let hosts: HostConfig = { githubHosts: [], gitlabHosts: [] }
101let githubTokenOption: string | undefined
102// Mirrors of $.state, so /clear (which resets $.state) can put the pane back as it was.
103let lastView: MergeWatchView = INITIAL_VIEW
104let lastSnapshot: MergeWatchSnapshot | null = null
105let lastExpanded: Record<string, boolean> = {}
106
107// ---------------------------------------------------------------------------
108// Small helpers
109// ---------------------------------------------------------------------------
110
111export function formatTime(ms: number | null): string {
112 if (ms === null) {
113 return '—'
114 }
115
116 const d = new Date(ms)
117 const pad = (n: number) => String(n).padStart(2, '0')
118
119 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
120}
121
122function noun(repo: MergeWatchRepo | null, count: number): string {
123 const word = repo?.provider === 'github' ? 'PR' : 'MR'
124
125 return `${count} open ${word}${count === 1 ? '' : 's'}`
126}
127
128/**
129 * Folds a fresh result into the previous one: a request whose details failed this time keeps
130 * its last good data, labelled stale with the time it dates from.
131 */
132export function mergeRequests(
133 previous: MergeWatchSnapshot | null,
134 fresh: readonly MergeWatchRequest[],
135): MergeWatchRequest[] {
136 const before = new Map((previous?.requests ?? []).map(r => [r.key, r]))
137
138 return sortRequests(
139 fresh.map(r => {
140 if (r.error === null) {
141 return r
142 }
143
144 const old = before.get(r.key)
145
146 if (old === undefined || (old.error !== null && old.staleSince === null)) {
147 return r
148 }
149
150 return { ...old, title: r.title, error: r.error, staleSince: old.staleSince ?? previous?.fetchedAt ?? null }
151 }),
152 )
153}
154
155/** After a failed refresh: the previous data stays, every request marked stale. */
156export function staleSnapshot(
157 previous: MergeWatchSnapshot | null,
158 repoKey: string,
159 patch: Pick<MergeWatchSnapshot, 'error' | 'authHelp' | 'nextRetryAt'>,
160): MergeWatchSnapshot {
161 if (previous === null || previous.repoKey !== repoKey) {
162 return { repoKey, fetchedAt: null, requests: [], ...patch }
163 }
164
165 return {
166 ...previous,
167 ...patch,
168 requests: previous.requests.map(r => ({ ...r, staleSince: r.staleSince ?? previous.fetchedAt })),
169 }
170}
171
172// ---------------------------------------------------------------------------
173// State writes (each mirrored for /clear)
174// ---------------------------------------------------------------------------
175
176/**
177 * Fills in any field an older version of Merge Watch did not write. $.state outlives a plugin
178 * update within a session, so a view saved by an earlier release can lack newer fields.
179 */
180export function normalizeView(v: Partial<MergeWatchView> | null | undefined): MergeWatchView {
181 return {
182 ...INITIAL_VIEW,
183 ...(v ?? {}),
184 candidates: Array.isArray(v?.candidates) ? v.candidates : [],
185 confirm: v?.confirm ?? null,
186 notices: v?.notices !== null && typeof v?.notices === 'object' ? v.notices : {},
187 }
188}
189
190async function readView($: $): Promise<MergeWatchView> {
191 return normalizeView(await read($, viewAtom))
192}
193
194async function setView($: $, fn: (v: MergeWatchView) => MergeWatchView): Promise<void> {
195 await update($, viewAtom, v => {
196 lastView = fn(normalizeView(v))
197
198 return lastView
199 })
200}
201
202async function setSnapshot($: $, value: MergeWatchSnapshot | null): Promise<void> {
203 lastSnapshot = value
204 await update($, snapshotAtom, () => value)
205}
206
207async function setExpanded($: $, value: Record<string, boolean>): Promise<void> {
208 lastExpanded = value
209 await update($, expandedAtom, () => value)
210}
211
212// ---------------------------------------------------------------------------
213// Repository and preferences
214// ---------------------------------------------------------------------------
215
216type CheckoutPrefs = { enabled?: boolean; selection?: string }
217
218/** Preferences for this checkout: keyed by the git common dir, shared by its worktrees. */
219async function loadCheckoutPrefs($: $): Promise<CheckoutPrefs> {
220 if (checkout === null) {
221 return {}
222 }
223
224 const saved = await $.store.get(`checkout:${checkout.common}`)
225
226 return saved !== null && typeof saved === 'object' ? (saved as CheckoutPrefs) : {}
227}
228
229async function saveCheckoutPrefs($: $, patch: CheckoutPrefs): Promise<void> {
230 if (checkout === null) {
231 return
232 }
233
234 const key = `checkout:${checkout.common}`
235 const current = await $.store.get(key)
236 const base = current !== null && typeof current === 'object' ? (current as CheckoutPrefs) : {}
237 await $.store.set(key, { ...base, ...patch })
238}
239
240async function git($: $, args: readonly string[], cwd: string): Promise<string | null> {
241 try {
242 const result = await $.process.run(['git', ...args], { cwd, timeoutMs: GIT_TIMEOUT_MS })
243
244 return result.exitCode === 0 ? result.stdout.trim() : null
245 } catch {
246 return null
247 }
248}
249
250/** Finds the checkout from the session's directory (nested folders and worktrees included) and picks a repo. */
251async function resolveRepo($: $): Promise<void> {
252 const cwd = await $.session.cwd()
253 const top = await git($, ['rev-parse', '--show-toplevel'], cwd)
254
255 if (top === null || top === '') {
256 checkout = null
257 await setView($, v => ({ ...v, phase: 'not-git', repo: null, candidates: [] }))
258
259 return
260 }
261
262 const common = (await git($, ['rev-parse', '--path-format=absolute', '--git-common-dir'], top)) ?? top
263 checkout = { top, common }
264 const remotes = (await git($, ['remote', '-v'], top)) ?? ''
265 const candidates = reposFromRemotes(remotes, hosts)
266 const prefs = await loadCheckoutPrefs($)
267 const choice = chooseRepo(candidates, prefs.selection ?? null)
268
269 if (choice.kind === 'none') {
270 await setView($, v => ({ ...v, phase: 'no-remote', repo: null, candidates: [] }))
271
272 return
273 }
274
275 if (choice.kind === 'ambiguous') {
276 await setView($, v => ({ ...v, phase: 'choose-repo', repo: null, candidates: choice.candidates, isChoosing: true }))
277
278 return
279 }
280
281 await useRepo($, choice.repo, choice.candidates, prefs.enabled !== false)
282}
283
284async function useRepo($: $, repo: MergeWatchRepo, candidates: MergeWatchRepo[], enabled: boolean): Promise<void> {
285 const saved = await $.store.get(`expanded:${repo.key}`)
286 await setExpanded($, saved !== null && typeof saved === 'object' ? (saved as Record<string, boolean>) : {})
287 const showAll = (await $.store.get(`showAll:${repo.key}`)) === true
288 await update($, showAllAtom, () => showAll)
289
290 const snapshot = await read($, snapshotAtom)
291
292 if (snapshot !== null && snapshot.repoKey !== repo.key) {
293 await setSnapshot($, null)
294 }
295
296 await setView($, v => ({ ...v, phase: enabled ? 'ready' : 'off', repo, candidates, isChoosing: false }))
297}
298
299async function selectRepo($: $, repo: MergeWatchRepo): Promise<void> {
300 const view = await readView($)
301 await saveCheckoutPrefs($, { selection: repo.key })
302 const prefs = await loadCheckoutPrefs($)
303 await useRepo($, repo, view.candidates, prefs.enabled !== false)
304
305 if (prefs.enabled !== false) {
306 startMonitoring($)
307 }
308}
309
310// ---------------------------------------------------------------------------
311// Fetching
312// ---------------------------------------------------------------------------
313
314/** Races `work` against a timeout on the mods clock; the late answer is ignored. */
315async function withTimeout<T>($: $, work: Promise<T>, ms: number, what: string): Promise<T> {
316 let handle: Timer | undefined
317 const timeout = new Promise<never>((_, reject) => {
318 handle = $.clock.after(ms, () => reject(new Error(`${what} timed out after ${Math.round(ms / 1000)}s`)))
319 })
320
321 try {
322 return await Promise.race([work, timeout])
323 } finally {
324 handle?.cancel()
325 }
326}
327
328/** Runs glab by argument vector in the checkout; rejects with its stderr. */
329function glabFor($: $): GlabRunner {
330 const cwd = checkout?.top
331
332 return async args => {
333 const result = await $.process.run(['glab', ...args], { cwd, timeoutMs: GLAB_TIMEOUT_MS })
334
335 if (result.exitCode !== 0) {
336 throw new Error(result.stderr.trim() || result.stdout.trim() || `glab exited with ${result.exitCode}`)
337 }
338
339 return result.stdout
340 }
341}
342
343/** Any-method HTTP for the GitHub write actions, with the same timeout as reads. */
344function sendFor($: $): HttpSend {
345 return (url, init) => withTimeout<HttpResponse>($, $.http.fetch(url, init), HTTP_TIMEOUT_MS, 'GitHub request')
346}
347
348async function githubToken($: $): Promise<string | undefined> {
349 return githubTokenOption ?? (await $.env.get('GITHUB_TOKEN')) ?? (await $.env.get('GH_TOKEN'))
350}
351
352async function fetchRequests($: $, repo: MergeWatchRepo, now: number): Promise<MergeWatchRequest[]> {
353 if (repo.provider === 'gitlab') {
354 return fetchGitLab(repo, glabFor($), now)
355 }
356
357 const token = await githubToken($)
358
359 return fetchGitHub(
360 repo,
361 (url, headers) => withTimeout<HttpResponse>($, $.http.fetch(url, { method: 'GET', headers }), HTTP_TIMEOUT_MS, 'GitHub request'),
362 token,
363 now,
364 )
365}
366
367/**
368 * Refreshes once. A call while a refresh of the same monitoring generation is running joins it.
369 * A result that arrives after the repository or generation changed is thrown away.
370 */
371async function refresh($: $, isManual: boolean): Promise<void> {
372 if (inflight !== null) {
373 if (inflightGeneration === generation) {
374 return inflight
375 }
376
377 await inflight.catch(() => undefined)
378 }
379
380 const view = await readView($)
381 const repo = view.repo
382
383 if (view.phase !== 'ready' || repo === null) {
384 return
385 }
386
387 const now = await $.clock.now()
388 const previous = await read($, snapshotAtom)
389
390 if (!isManual && previous?.repoKey === repo.key && previous.nextRetryAt !== null && previous.nextRetryAt > now) {
391 return
392 }
393
394 const gen = generation
395 inflightGeneration = gen
396 inflight = (async () => {
397 await setView($, v => ({ ...v, isRefreshing: true }))
398
399 try {
400 const fresh = await fetchRequests($, repo, now)
401 const current = await readView($)
402
403 if (gen !== generation || current.repo?.key !== repo.key) {
404 return
405 }
406
407 const before = await read($, snapshotAtom)
408 await setSnapshot($, {
409 repoKey: repo.key,
410 fetchedAt: await $.clock.now(),
411 requests: mergeRequests(before?.repoKey === repo.key ? before : null, fresh),
412 error: null,
413 authHelp: null,
414 nextRetryAt: null,
415 })
416 } catch (error) {
417 const current = await readView($)
418
419 if (gen !== generation || current.repo?.key !== repo.key) {
420 return
421 }
422
423 const before = await read($, snapshotAtom)
424 await setSnapshot(
425 $,
426 staleSnapshot(before, repo.key, {
427 error: cleanError(error),
428 authHelp: error instanceof AuthError ? error.help : null,
429 nextRetryAt: error instanceof RateLimitError ? error.retryAt : null,
430 }),
431 )
432 } finally {
433 if (gen === generation) {
434 await setView($, v => ({ ...v, isRefreshing: false }))
435 }
436
437 inflight = null
438 }
439 })()
440
441 return inflight
442}
443
444// ---------------------------------------------------------------------------
445// Monitoring and the pane
446// ---------------------------------------------------------------------------
447
448/** Fetches now, then every minute. Any earlier timer of this module is cancelled first. */
449/**
450 * The once-a-minute timer. Created in session.start, as the mods reference asks for work that
451 * outlives one event: a timer started inside a command's handler did not keep firing. Ticks are
452 * free until monitoring starts. Any earlier timer of this module is cancelled first.
453 */
454function startTimer($: $): void {
455 timer?.cancel()
456 timer = $.clock.every(REFRESH_MS, () => {
457 if (isMonitoring) {
458 void refresh($, false)
459 }
460 })
461}
462
463/** Fetches now; the session's timer then refreshes every minute. */
464function startMonitoring($: $): void {
465 generation += 1
466 isMonitoring = true
467 void refresh($, true)
468}
469
470function stopMonitoring(): void {
471 generation += 1
472 isMonitoring = false
473}
474
475/** Opens the pane with the keyboard. Only ever called because someone asked for it. */
476async function openPane($: $): Promise<void> {
477 const repo = (await readView($)).repo
478
479 await $.ui.open({ id: PANE_ID, title: repo === null ? 'Merge Watch' : `Merge Watch · ${repo.path}`, focus: true })
480}
481
482async function setEnabled($: $, enabled: boolean): Promise<void> {
483 await saveCheckoutPrefs($, { enabled })
484 const view = await readView($)
485
486 if (view.repo !== null) {
487 await setView($, v => ({ ...v, phase: enabled ? 'ready' : 'off' }))
488 }
489
490 if (enabled) {
491 startMonitoring($)
492 } else {
493 stopMonitoring()
494 await setView($, v => ({ ...v, isRefreshing: false }))
495 }
496}
497
498async function toggleExpanded($: $, key: string): Promise<void> {
499 const view = await readView($)
500 const current = await read($, expandedAtom)
501 const next = { ...current, [key]: current[key] !== true }
502 await setExpanded($, next)
503
504 if (view.repo !== null) {
505 await $.store.set(`expanded:${view.repo.key}`, next)
506 }
507}
508
509async function copyLink($: $, url: string, surface: 'terminal' | 'desktop' | 'mobile' | 'vscode'): Promise<void> {
510 const copied = await $.ui.copy({ text: url, surface })
511 $.ui.toast(copied.isCopied ? 'Link copied' : `Copy this link: ${url}`)
512}
513
514/**
515 * Starts Merge Watch on request: finds the repository, starts the once-a-minute refresh and
516 * opens the pane with the keyboard. Nothing runs before someone asks for it.
517 */
518async function activate($: $): Promise<void> {
519 await resolveRepo($)
520 const view = await readView($)
521
522 if (view.repo !== null) {
523 await saveCheckoutPrefs($, { enabled: true })
524 await setView($, v => ({ ...v, phase: 'ready' }))
525 startMonitoring($)
526 }
527
528 await openPane($)
529}
530
531/** A plain `claude -p` run has no app attached, so there is nowhere to show the pane. */
532async function hasSurface($: $): Promise<boolean> {
533 return (await $.session.surfaces()).length > 0
534}
535
536const NO_SURFACE = 'Merge Watch needs an app that can show its panel, such as the terminal or the desktop app.'
537const NOT_RUNNING = "Merge Watch isn't running in this session. Run /merge-watch to start it."
538
539/** Expands every listed row, or collapses them all when they are all open already. */
540async function toggleExpandAll($: $, keys: readonly string[]): Promise<void> {
541 const view = await readView($)
542 const current = await read($, expandedAtom)
543 const allOpen = keys.length > 0 && keys.every(k => current[k] === true)
544 const next = { ...current }
545
546 for (const k of keys) {
547 next[k] = !allOpen
548 }
549
550 await setExpanded($, next)
551
552 if (view.repo !== null) {
553 await $.store.set(`expanded:${view.repo.key}`, next)
554 }
555}
556
557async function toggleShowAll($: $): Promise<void> {
558 const view = await readView($)
559 const next = !(await read($, showAllAtom))
560 await update($, showAllAtom, () => next)
561
562 if (view.repo !== null) {
563 await $.store.set(`showAll:${view.repo.key}`, next)
564 }
565}
566
567async function toggleMoreFailed($: $, key: string): Promise<void> {
568 await update($, moreFailedAtom, m => ({ ...m, [key]: m[key] !== true }))
569}
570
571async function setNotice($: $, key: string, text: string | null): Promise<void> {
572 await setView($, v => {
573 const notices = { ...v.notices }
574
575 if (text === null) {
576 delete notices[key]
577 } else {
578 notices[key] = text
579 }
580
581 return { ...v, notices }
582 })
583}
584
585/**
586 * Runs a confirmed write action on one request, then refreshes so the pane shows the result.
587 * Retry re-runs only failures that are not allowed to fail; merge is pinned to the commit shown.
588 */
589async function runAction($: $, key: string, action: 'retry' | 'merge'): Promise<void> {
590 const view = await readView($)
591 const snapshot = await read($, snapshotAtom)
592 const repo = view.repo
593 const request = snapshot?.requests.find(r => r.key === key)
594 await setView($, v => ({ ...v, confirm: null }))
595
596 if (repo === null || request === undefined) {
597 return
598 }
599
600 await setNotice($, key, action === 'retry' ? 'Retrying failed jobs…' : 'Merging…')
601 const now = await $.clock.now()
602
603 try {
604 if (action === 'retry') {
605 const count =
606 repo.provider === 'gitlab'
607 ? await retryFailedGitLab(repo, glabFor($), request, now)
608 : await retryFailedGitHub(repo, sendFor($), await githubToken($), request, now)
609 await setNotice($, key, count === 0 ? 'No failed jobs to retry.' : `Retried ${count} failed job${count === 1 ? '' : 's'}.`)
610 } else {
611 if (repo.provider === 'gitlab') {
612 await mergeGitLab(repo, glabFor($), request, now)
613 } else {
614 await mergeGitHub(repo, sendFor($), await githubToken($), request, now)
615 }
616
617 await setNotice($, key, 'Merge requested.')
618 }
619 } catch (error) {
620 await setNotice($, key, `! ${action === 'retry' ? 'Retry' : 'Merge'} failed: ${cleanError(error)}`)
621 }
622
623 void refresh($, true)
624}
625
626/**
627 * Splits `total` cells among segments in proportion to their counts, never below each
628 * segment's `min` (its label plus padding), and always summing to exactly `total` when it can.
629 */
630export function segmentWidths(segments: readonly { count: number; min: number }[], total: number): number[] {
631 const sum = segments.reduce((n, s) => n + s.count, 0)
632 const widths = segments.map(s => Math.max(s.min, Math.round((s.count / Math.max(1, sum)) * total)))
633 let over = widths.reduce((n, w) => n + w, 0) - total
634
635 // Take any excess from the widest segments first, never below their minimum.
636 while (over > 0) {
637 let widest = -1
638
639 widths.forEach((w, i) => {
640 if (w > segments[i]!.min && (widest === -1 || w > widths[widest]!)) {
641 widest = i
642 }
643 })
644
645 if (widest === -1) {
646 break
647 }
648
649 widths[widest] = widths[widest]! - 1
650 over -= 1
651 }
652
653 // Hand any shortfall to the widest segment so the bar ends flush.
654 if (over < 0 && widths.length > 0) {
655 const widest = widths.indexOf(Math.max(...widths))
656 widths[widest] = widths[widest]! - over
657 }
658
659 return widths
660}
661
662const USAGE =
663 'Use /merge-watch to open the panel, or add refresh, hide, off, on or repo (for example /merge-watch refresh).'
664
665// ---------------------------------------------------------------------------
666// Hooks
667// ---------------------------------------------------------------------------
668
669export function registerMergeWatch(on: On, options: PluginOptions): void {
670 hosts = {
671 githubHosts: splitHosts(options.github_hosts),
672 gitlabHosts: splitHosts(options.gitlab_hosts),
673 }
674 githubTokenOption = typeof options.github_token === 'string' && options.github_token.trim() !== '' ? options.github_token : undefined
675
676 on('session.start', async ($, e, next) => {
677 await $.command.register({
678 name: 'merge-watch',
679 description: 'Show open merge requests and their CI in a panel',
680 argumentHint: '[refresh|hide|off|on|repo]',
681 immediate: true,
682 })
683
684 // The timer starts here but does nothing until someone runs /merge-watch: no repository
685 // lookup, no refresh, no pane before that.
686 startTimer($)
687
688 return next(e)
689 })
690
691 // /clear, /resume and /branch reset $.state but not the module: put the pane back.
692 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
693 await update($, viewAtom, () => ({ ...lastView, isRefreshing: false }))
694 await update($, snapshotAtom, () => lastSnapshot)
695 await update($, expandedAtom, () => lastExpanded)
696
697 return next(e)
698 })
699
700 on('session.end', async ($, e, next) => {
701 if (e.reason !== 'clear' && e.reason !== 'resume') {
702 stopMonitoring()
703 timer?.cancel()
704 timer = undefined
705 }
706
707 return next(e)
708 })
709
710 on('command.run', { command: 'merge-watch' }, async ($, e) => {
711 const [action = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
712
713 const verb = action.toLowerCase()
714 const before = await readView($)
715 const isStopped = before.phase === 'idle' || before.phase === 'off'
716
717 // Starting (or restarting) is only ever this command's doing.
718 if (isStopped && (verb === '' || verb === 'on' || verb === 'refresh')) {
719 if (!(await hasSurface($))) {
720 return { text: NO_SURFACE }
721 }
722
723 await activate($)
724
725 return verb === '' ? {} : { text: 'Merge Watch is on.' }
726 }
727
728 if (before.phase === 'idle' && (verb === 'hide' || verb === 'off')) {
729 return { text: NOT_RUNNING }
730 }
731
732 if (before.phase === 'idle' && verb === 'repo') {
733 await resolveRepo($)
734 }
735
736 const view = await readView($)
737
738 switch (verb) {
739 case '':
740 await openPane($)
741
742 return {}
743 case 'refresh': {
744 if (view.phase !== 'ready') {
745 return { text: view.phase === 'off' ? 'Merge Watch is off. Run /merge-watch on to resume.' : 'Merge Watch has no repository to refresh yet.' }
746 }
747
748 const snapshot = await read($, snapshotAtom)
749 const now = await $.clock.now()
750
751 if (snapshot?.nextRetryAt != null && snapshot.nextRetryAt > now) {
752 return { text: `The provider asked Merge Watch to slow down. Next try at ${formatTime(snapshot.nextRetryAt)}.` }
753 }
754
755 void refresh($, true)
756
757 return { text: 'Refreshing Merge Watch.' }
758 }
759 case 'hide':
760 await $.ui.close({ id: PANE_ID })
761
762 return { text: 'Merge Watch is hidden and still refreshing. Run /merge-watch to show it.' }
763 case 'off':
764 await setEnabled($, false)
765 await $.ui.close({ id: PANE_ID })
766
767 return { text: 'Merge Watch is off for this repository. Run /merge-watch on to resume.' }
768 case 'on':
769 await activate($)
770
771 return { text: 'Merge Watch is on.' }
772 case 'repo': {
773 const wanted = rest.join(' ')
774
775 if (wanted !== '') {
776 const match = view.candidates.find(r => r.remoteName === wanted || r.key === wanted || r.path === wanted)
777
778 if (match === undefined) {
779 return { text: `No remote named "${wanted}". Choices: ${view.candidates.map(r => r.remoteName).join(', ') || 'none'}.` }
780 }
781
782 await selectRepo($, match)
783
784 return { text: `Merge Watch now watches ${match.host}/${match.path}.` }
785 }
786
787 await setView($, v => ({ ...v, isChoosing: true }))
788 await openPane($)
789 const current = view.repo === null ? 'none selected' : `${view.repo.host}/${view.repo.path} (${view.repo.remoteName})`
790
791 return { text: `Watching: ${current}. Pick another in the panel, or run /merge-watch repo <remote>.` }
792 }
793 default:
794 return { text: USAGE }
795 }
796 })
797
798 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
799 const { Box, Text, Button, Link } = $.ui.resolve(e)
800 const svgTable = e.surface === 'desktop' ? $.ui.resolve(e) : null
801 const Svg = svgTable !== null && 'Svg' in svgTable ? svgTable.Svg : null
802 const view = await readView($)
803 const snapshot = await read($, snapshotAtom)
804 const expanded = await read($, expandedAtom)
805 const showAll = await read($, showAllAtom)
806 const moreFailed = await read($, moreFailedAtom)
807 const now = await $.clock.now()
808 const columns = Number(e.props.bodyColumns)
809 // Some surfaces may not report a width; lay out for a typical sidebar then.
810 const W = Number.isFinite(columns) && columns > 0 ? Math.max(32, columns) : 60
811 const repo = view.repo
812 const data = snapshot !== null && repo !== null && snapshot.repoKey === repo.key ? snapshot : null
813
814 const IID_COLS = 7
815 const BAR_CELLS = 10
816 const TITLE_COLS = Math.max(10, W - IID_COLS - BAR_CELLS - 4)
817 // The desktop app frames the pane with its own title bar (title, maximise, close); the
818 // terminal shows no title while one pane is open, so it gets the header row.
819 const hasOwnChrome = e.surface !== 'terminal'
820
821 // The rows the ⤢ button opens or closes; filled in once the list is known.
822 let visibleKeys: string[] = []
823
824 const clip = (text: string, cols: number) => (text.length <= cols ? text : `${text.slice(0, Math.max(1, cols - 1))}…`)
825
826 const header = hasOwnChrome ? null : (
827 <Box key="header" flexDirection="row" justifyContent="space-between" columnGap={1}>
828 <Text wrap="truncate-end">
829 <Text color={C.muted}>Merge Watch · </Text>
830 <Text bold>{repo === null ? '' : repo.path}</Text>
831 </Text>
832 <Box flexDirection="row" columnGap={1}>
833 <Button key="refresh" plain label="↻" onPress={() => refresh($, true)} />
834 <Button key="expand" plain label="⤢" onPress={() => toggleExpandAll($, visibleKeys)} />
835 <Button key="close" plain label="✕" onPress={() => $.ui.close({ id: PANE_ID })} />
836 </Box>
837 </Box>
838 )
839
840 const message = (key: string, text: string, color?: string) => (
841 <Text key={key} color={color} wrap="wrap">
842 {text}
843 </Text>
844 )
845
846 const frame = (...children: (RenderElement | null)[]) => (
847 <Box flexDirection="column" width={W} rowGap={0}>
848 {children}
849 </Box>
850 )
851
852 if (view.phase === 'not-git') {
853 return frame(header, message('msg', 'Open a Git project to use Merge Watch.'))
854 }
855
856 if (view.phase === 'no-remote') {
857 return frame(
858 header,
859 message('msg', 'This repository has no GitLab or GitHub remote Merge Watch recognises. For a self-hosted host without "gitlab" in its name, add it to the gitlab_hosts or github_hosts option.'),
860 )
861 }
862
863 if (view.phase === 'idle') {
864 return frame(header, message('msg', 'Finding the repository…', C.muted))
865 }
866
867 const chooser =
868 view.isChoosing || view.phase === 'choose-repo' ? (
869 <Box key="chooser" flexDirection="column">
870 <Text color={C.muted}>{view.phase === 'choose-repo' ? 'Several remotes could be the project. Pick one:' : 'Watch another remote:'}</Text>
871 {view.candidates.map(r => (
872 <Button
873 key={`pick-${r.remoteName}`}
874 plain
875 label={`${repo?.key === r.key ? '● ' : '○ '}${r.remoteName}: ${r.host}/${r.path}`}
876 onPress={() => selectRepo($, r)}
877 />
878 ))}
879 </Box>
880 ) : null
881
882 if (view.phase === 'choose-repo') {
883 return frame(header, chooser)
884 }
885
886 if (view.phase === 'off') {
887 return frame(
888 header,
889 message('msg', 'Monitoring is off for this repository.', C.muted),
890 <Button key="turn-on" label="Turn on" onPress={() => setEnabled($, true)} />,
891 )
892 }
893
894 // ---- data ------------------------------------------------------------
895 const all = data?.requests ?? []
896 const shown = showAll ? all : all.filter(r => !isStale(r, now))
897 const groups = new Map<Group, MergeWatchRequest[]>(GROUP_ORDER.map(g => [g, []]))
898
899 for (const r of shown) {
900 groups.get(groupOf(r))!.push(r)
901 }
902
903 visibleKeys = shown.map(r => r.key)
904
905 // ---- status lines (loading, errors, sign-in) -------------------------
906 const status: RenderElement[] = []
907
908 if (data === null || data.fetchedAt === null) {
909 status.push(message('loading', view.isRefreshing ? 'Loading merge requests…' : 'Not loaded yet.', C.muted))
910 }
911
912 if (data?.error != null) {
913 status.push(
914 message(
915 'error',
916 data.fetchedAt === null
917 ? `! Couldn't load: ${data.error}`
918 : `! Couldn't refresh: ${data.error}. Showing stale data from ${formatTime(data.fetchedAt)}.`,
919 C.amber,
920 ),
921 )
922 }
923
924 if (data?.authHelp != null) {
925 status.push(message('auth', data.authHelp))
926 }
927
928 if (data?.nextRetryAt != null) {
929 status.push(message('retry', `Polling slowed by the provider. Next try at ${formatTime(data.nextRetryAt)}.`, C.amber))
930 }
931
932 // ---- summary bar -----------------------------------------------------
933 const segments = GROUP_ORDER.map(g => ({ group: g, count: groups.get(g)!.length })).filter(s => s.count > 0)
934 const segmentText = (g: Group, n: number) => (g === 'ready' ? `${n} ready` : g === 'failing' ? `${n} failing` : `${n} draft${n === 1 ? '' : 's'}`)
935 let summary: RenderElement | null = null
936
937 if (segments.length > 0) {
938 const gaps = segments.length - 1
939 const widths = segmentWidths(
940 segments.map(s => ({ count: s.count, min: segmentText(s.group, s.count).length + 2 })),
941 W - gaps,
942 )
943 summary = (
944 <Box key="summary" flexDirection="row" columnGap={1} marginTop={hasOwnChrome ? 0 : 1}>
945 {segments.map((s, i) => (
946 <Box key={`seg-${s.group}`} width={widths[i]} flexShrink={0} backgroundColor={TINT[s.group].bg}>
947 <Text bold color={TINT[s.group].fg} backgroundColor={TINT[s.group].bg} wrap="truncate">
948 {` ${segmentText(s.group, s.count)}`}
949 </Text>
950 </Box>
951 ))}
952 </Box>
953 )
954 }
955
956 // ---- one request -----------------------------------------------------
957 const miniBar = (key: string, mix: JobMix) => {
958 if (Svg !== null) {
959 const total = mix.passed + mix.failed + mix.allowed + mix.manual + mix.other
960 let x = 0
961 const rects = (['passed', 'failed', 'allowed', 'manual', 'other'] as const)
962 .filter(k => mix[k] > 0)
963 .map(k => {
964 const w = total === 0 ? 44 : (mix[k] / total) * 44
965 const r = `<rect x="${x.toFixed(2)}" y="0" width="${w.toFixed(2)}" height="6" fill="${BAR[k]}"/>`
966 x += w
967
968 return r
969 })
970
971 return (
972 <Svg
973 key={key}
974 source={`<svg xmlns="http://www.w3.org/2000/svg" width="44" height="6" viewBox="0 0 44 6"><clipPath id="r"><rect width="44" height="6" rx="3"/></clipPath><g clip-path="url(#r)">${total === 0 ? `<rect width="44" height="6" fill="${BAR.other}"/>` : rects.join('')}</g></svg>`}
975 alt={`${mix.passed} passed, ${mix.failed} failed, ${mix.allowed} allowed to fail, ${mix.manual} manual`}
976 width={44}
977 height={6}
978 />
979 )
980 }
981
982 const cells = splitCells(mix, BAR_CELLS)
983
984 return (
985 <Text key={key}>
986 {(['passed', 'failed', 'allowed', 'manual', 'other'] as const)
987 .filter(k => cells[k] > 0)
988 .map(k => (
989 <Text key={k} color={BAR[k]}>
990 {'━'.repeat(cells[k])}
991 </Text>
992 ))}
993 </Text>
994 )
995 }
996
997 const reasonColor = (r: MergeWatchRequest, g: Group) => (g === 'draft' ? C.muted : g === 'ready' ? C.green : reasonLine(r).startsWith('✕') ? C.red : C.muted)
998
999 const renderRow = (r: MergeWatchRequest, g: Group): RenderElement[] => {
1000 const isOpen = expanded[r.key] === true
1001 const out: RenderElement[] = [
1002 <Box key={`row-${r.key}`} flexDirection="row" columnGap={1}>
1003 <Box width={IID_COLS} flexShrink={0}>
1004 <Text color={C.muted}>{r.ref}</Text>
1005 </Box>
1006 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
1007 <Button key={`toggle-${r.key}`} plain label={clip(stripPrefix(r.title), TITLE_COLS)} onPress={() => toggleExpanded($, r.key)} />
1008 <Text color={reasonColor(r, g)} wrap="truncate-end">
1009 {clip(reasonLine(r), TITLE_COLS)}
1010 </Text>
1011 </Box>
1012 <Box flexDirection="row" columnGap={1} flexShrink={0}>
1013 {miniBar(`bar-${r.key}`, jobMix(r))}
1014 <Button key={`chev-${r.key}`} plain dimColor label={isOpen ? '▾' : '▸'} onPress={() => toggleExpanded($, r.key)} />
1015 </Box>
1016 </Box>,
1017 ]
1018
1019 if (isOpen) {
1020 out.push(renderDetail(r, g))
1021 }
1022
1023 return out
1024 }
1025
1026 const renderDetail = (r: MergeWatchRequest, g: Group): RenderElement => {
1027 const k = `d-${r.key}`
1028 const head = r.ci.pipelines[0]
1029 const failed = realFailures(r)
1030 const allowed = allowedFailures(r)
1031 const running = currentJobs(r).filter(j => j.state === 'running')
1032 const showEvery = moreFailed[r.key] === true
1033 const listedFailures = showEvery ? failed : failed.slice(0, FAILURES_SHOWN)
1034 const notice = view.notices[r.key]
1035 const confirm = view.confirm !== null && view.confirm.key === r.key ? view.confirm.action : null
1036 const label = (text: string) => (
1037 <Box width={10} flexShrink={0}>
1038 <Text color={C.muted}>{text}</Text>
1039 </Box>
1040 )
1041 const jobRow = (id: string, name: string, url: string | null, word: string, color: string, isMuted: boolean) => (
1042 <Box key={`${k}-j${id}`} flexDirection="row" justifyContent="space-between" columnGap={1}>
1043 <Text color={isMuted ? C.muted : undefined} wrap="truncate-end">
1044 {url === null ? name : <Link href={url} label={name} />}
1045 </Text>
1046 <Text color={color}>{word}</Text>
1047 </Box>
1048 )
1049 const linkButton = (key: string, text: string, url: string) => (
1050 <Box key={key} borderStyle="round" paddingX={1}>
1051 <Link href={url} label={text} />
1052 </Box>
1053 )
1054 const actions: RenderElement[] = []
1055
1056 if (confirm !== null) {
1057 actions.push(
1058 <Button
1059 key={`confirm-${r.key}`}
1060 variant="primary"
1061 label={confirm === 'merge' ? `Confirm merge of ${r.ref}` : `Confirm retry of ${failed.length} job${failed.length === 1 ? '' : 's'}`}
1062 onPress={() => runAction($, r.key, confirm)}
1063 />,
1064 <Button key={`cancel-${r.key}`} label="Cancel" onPress={() => setView($, v => ({ ...v, confirm: null }))} />,
1065 )
1066 } else if (g === 'ready' && r.canMerge && !r.needsReview) {
1067 actions.push(<Button key={`merge-${r.key}`} variant="primary" label="Merge" onPress={() => setView($, v => ({ ...v, confirm: { key: r.key, action: 'merge' } }))} />)
1068 } else if (g === 'ready' && r.url !== null) {
1069 actions.push(linkButton(`review-${r.key}`, 'Review', r.url))
1070 } else if (failed.length > 0) {
1071 actions.push(<Button key={`retry-${r.key}`} variant="primary" label="Retry failed" onPress={() => setView($, v => ({ ...v, confirm: { key: r.key, action: 'retry' } }))} />)
1072 }
1073
1074 if (confirm === null && r.url !== null) {
1075 if (!(g === 'ready' && !r.canMerge)) {
1076 actions.push(linkButton(`open-${r.key}`, 'Open MR', r.url))
1077 }
1078
1079 const url = r.url
1080 actions.push(<Button key={`copy-${r.key}`} label="Copy link" onPress={press => copyLink($, url, press.surface)} />)
1081 }
1082
1083 return (
1084 <Box key={k} flexDirection="column" paddingLeft={IID_COLS + 1} marginBottom={1}>
1085 <Text wrap="wrap">{r.title}</Text>
1086 <Box flexDirection="row">
1087 {label('Status')}
1088 <Text wrap="truncate-end">{[r.isDraft ? 'Draft' : 'Open', r.review, ...r.blockers.filter(b => b !== r.review && b !== 'Draft')].join(' · ')}</Text>
1089 </Box>
1090 <Box flexDirection="row">
1091 {label('Branch')}
1092 <Text wrap="truncate-end">{`${r.sourceBranch} → ${r.targetBranch} · ${r.author}${r.sourceProject === null ? '' : ` · from ${r.sourceProject}`}`}</Text>
1093 </Box>
1094 <Box flexDirection="row">
1095 {label('Pipeline')}
1096 <Text wrap="truncate-end">
1097 {head === undefined ? (
1098 r.ci.label
1099 ) : head.url === null ? (
1100 pipelineSummary(head, jobCounts(currentJobs(r)))
1101 ) : (
1102 <Link href={head.url} label={pipelineSummary(head, jobCounts(currentJobs(r)))} />
1103 )}
1104 </Text>
1105 </Box>
1106 {r.ci.pipelines.flatMap(p => p.notes).map((note, i) => (
1107 <Text key={`${k}-n${i}`} color={note.startsWith('Previous revision') ? C.amber : C.muted} wrap="truncate-end">
1108 {note}
1109 </Text>
1110 ))}
1111 {r.error !== null && (
1112 <Text color={C.amber} wrap="wrap">
1113 {r.staleSince === null ? `! Details unavailable: ${r.error}` : `! Couldn't refresh ${r.ref}: ${r.error}. Showing data from ${formatTime(r.staleSince)}.`}
1114 </Text>
1115 )}
1116 {listedFailures.length + allowed.length + running.length > 0 && (
1117 <Box flexDirection="column" borderStyle="round" paddingX={1} marginTop={1}>
1118 {listedFailures.map(j => jobRow(j.id, j.name, j.url, 'failed', C.red, false))}
1119 {running.map(j => jobRow(j.id, j.name, j.url, 'running', C.blue, false))}
1120 {allowed.map(j => jobRow(j.id, j.name, j.url, 'allowed', C.amber, true))}
1121 </Box>
1122 )}
1123 {failed.length > FAILURES_SHOWN && (
1124 <Button
1125 key={`more-${r.key}`}
1126 plain
1127 dimColor
1128 label={showEvery ? 'Show fewer' : `+${failed.length - FAILURES_SHOWN} more failed`}
1129 onPress={() => toggleMoreFailed($, r.key)}
1130 />
1131 )}
1132 {notice !== undefined && (
1133 <Text color={notice.startsWith('!') ? C.amber : C.muted} wrap="wrap">
1134 {notice}
1135 </Text>
1136 )}
1137 {actions.length > 0 && (
1138 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
1139 {actions}
1140 </Box>
1141 )}
1142 </Box>
1143 )
1144 }
1145
1146 // ---- groups ----------------------------------------------------------
1147 const body: RenderElement[] = []
1148
1149 for (const g of GROUP_ORDER) {
1150 const rows = groups.get(g)!
1151
1152 if (rows.length === 0) {
1153 continue
1154 }
1155
1156 const title = GROUP_LABEL[g]
1157 const count = String(rows.length)
1158 // Rule characters are wider than a cell in the desktop app's font, so draw fewer there.
1159 const ruleCells = Math.max(0, W - title.length - count.length - 3)
1160 body.push(
1161 <Box key={`group-${g}`} flexDirection="row" marginTop={1}>
1162 <Box flexShrink={0}>
1163 <Text bold color={g === 'ready' ? C.green : g === 'failing' ? C.red : C.muted} wrap="truncate">
1164 {title}
1165 </Text>
1166 </Box>
1167 <Box flexShrink={0}>
1168 <Text color={C.faint}>{` ${count} `}</Text>
1169 </Box>
1170 <Box flexGrow={1} flexShrink={1} overflow="hidden">
1171 <Text color={C.hairline} wrap="truncate">
1172 {'─'.repeat(hasOwnChrome ? Math.floor(ruleCells * 0.55) : ruleCells)}
1173 </Text>
1174 </Box>
1175 </Box>,
1176 )
1177
1178 for (const r of rows) {
1179 body.push(...renderRow(r, g))
1180 }
1181 }
1182
1183 if (data !== null && data.fetchedAt !== null && all.length === 0 && data.error === null) {
1184 body.push(message('empty', `No open ${repo?.provider === 'github' ? 'pull' : 'merge'} requests.`, C.muted))
1185 } else if (data !== null && shown.length === 0 && all.length > 0) {
1186 body.push(message('empty', `No ${repo?.provider === 'github' ? 'PRs' : 'MRs'} updated in the last 14 days.`, C.muted))
1187 }
1188
1189 // ---- footer ----------------------------------------------------------
1190 const hidden = all.length - shown.length
1191 const footer = (
1192 <Box key="footer" flexDirection="row" columnGap={1} marginTop={1}>
1193 <Text color={C.muted} wrap="truncate-end">
1194 {`${shown.length} of ${all.length} open · ${data?.fetchedAt == null ? 'not loaded' : `updated ${formatTime(data.fetchedAt)}`}${view.isRefreshing ? ' · refreshing…' : ''}`}
1195 </Text>
1196 {(hidden > 0 || showAll) && <Button key="show-all" plain label={showAll ? 'show recent' : 'show all'} onPress={() => toggleShowAll($)} />}
1197 {hasOwnChrome && <Button key="refresh" plain label="↻ refresh" onPress={() => refresh($, true)} />}
1198 {hasOwnChrome && <Button key="expand" plain label={visibleKeys.every(k => expanded[k] === true) && visibleKeys.length > 0 ? 'collapse all' : 'expand all'} onPress={() => toggleExpandAll($, visibleKeys)} />}
1199 </Box>
1200 )hooks/github.ts 573 lines1// GitHub adapter: the documented REST API over the mods network API, mapped to the shared
2// display model, plus the two write actions the pane offers after a confirm press.
3
4import type { MergeWatchCiState, MergeWatchJob, MergeWatchPipeline, MergeWatchRepo, MergeWatchRequest } from '../types'
5import { cleanError, cleanText, safeUrl } from './safe'
6import { AuthError, RateLimitError, STATE_WORD, ciLabel, job, mapLimit, rollup, rollupJobs } from './status'
7
8export type HttpResponse = { status: number; ok: boolean; headers: Record<string, string>; text: string }
9
10/** Performs one GET with the given headers. The caller adds timeouts and the credential. */
11export type HttpGet = (url: string, headers: Record<string, string>) => Promise<HttpResponse>
12
13/** Performs one request of any method; only the write actions use it. */
14export type HttpSend = (url: string, init: { method: string; headers: Record<string, string>; body?: string }) => Promise<HttpResponse>
15
16type Json = Record<string, unknown>
17
18const PR_CONCURRENCY = 4
19const RELEVANT_EVENTS = new Set(['pull_request', 'pull_request_target', 'push', 'merge_group'])
20
21function asRecord(value: unknown): Json {
22 return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {}
23}
24
25function str(value: unknown): string {
26 return typeof value === 'string' ? value : typeof value === 'number' ? String(value) : ''
27}
28
29export function apiBase(host: string): string {
30 return host === 'github.com' ? 'https://api.github.com' : `https://${host}/api/v3`
31}
32
33export function authHelp(host: string): string {
34 return (
35 `Merge Watch needs a GitHub token for ${host} with read access to pull requests, checks, actions and commit statuses. ` +
36 'Enter it in the plugin\'s github_token option (stored in your system keychain), or start Claude Code with GITHUB_TOKEN set in its environment. ' +
37 'Never paste a token into the chat. Then run /merge-watch refresh.'
38 )
39}
40
41/** The `rel="next"` URL of a Link header, if any. */
42export function nextLink(header: string | undefined): string | null {
43 if (header === undefined) {
44 return null
45 }
46
47 for (const part of header.split(',')) {
48 const match = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part)
49
50 if (match !== null) {
51 return match[1]!
52 }
53 }
54
55 return null
56}
57
58type Ctx = { repo: MergeWatchRepo; get: HttpGet; token: string; now: number }
59
60async function request(ctx: Ctx, url: string): Promise<HttpResponse> {
61 const response = await ctx.get(url, {
62 accept: 'application/vnd.github+json',
63 authorization: `Bearer ${ctx.token}`,
64 'x-github-api-version': '2022-11-28',
65 'user-agent': 'merge-watch-claude-code-mod',
66 })
67
68 if (response.status === 401) {
69 throw new AuthError(`GitHub rejected the token for ${ctx.repo.host}`, authHelp(ctx.repo.host))
70 }
71
72 const retryAfter = Number(response.headers['retry-after'])
73 const remaining = response.headers['x-ratelimit-remaining']
74
75 if (response.status === 429 || ((response.status === 403 || response.status === 429) && (remaining === '0' || Number.isFinite(retryAfter)))) {
76 const reset = Number(response.headers['x-ratelimit-reset'])
77 const retryAt = Number.isFinite(retryAfter) && retryAfter > 0
78 ? ctx.now + retryAfter * 1000
79 : Number.isFinite(reset) && reset > 0
80 ? reset * 1000
81 : ctx.now + 60_000
82
83 throw new RateLimitError(`GitHub rate limit reached for ${ctx.repo.host}`, retryAt)
84 }
85
86 if (!response.ok) {
87 let message = ''
88
89 try {
90 message = str(asRecord(JSON.parse(response.text)).message)
91 } catch {
92 message = ''
93 }
94
95 throw new Error(`GitHub answered ${response.status}${message === '' ? '' : `: ${cleanText(message, 120)}`}`)
96 }
97
98 return response
99}
100
101async function getJson(ctx: Ctx, url: string): Promise<unknown> {
102 return JSON.parse((await request(ctx, url)).text)
103}
104
105/** Follows every `next` link; `field` names the list inside a wrapped response (check_runs, jobs, ...). */
106async function getAll(ctx: Ctx, firstUrl: string, field?: string): Promise<Json[]> {
107 const out: Json[] = []
108 const seen = new Set<string>()
109 const base = new URL(apiBase(ctx.repo.host))
110 let url: string | null = firstUrl
111
112 while (url !== null && !seen.has(url)) {
113 seen.add(url)
114 const response = await request(ctx, url)
115 const body: unknown = JSON.parse(response.text)
116 const items = field === undefined ? body : asRecord(body)[field]
117
118 if (Array.isArray(items)) {
119 out.push(...items.map(asRecord))
120 }
121
122 const next = nextLink(response.headers.link)
123 // Only follow pages on the same API host.
124 url = next !== null && new URL(next).host === base.host ? next : null
125 }
126
127 return out
128}
129
130/** A check run, Actions job or workflow run (all share status + conclusion) → shared state. */
131export function mapCheckState(status: string, conclusion: string): { state: MergeWatchCiState; label: string } {
132 if (status !== 'completed') {
133 if (status === 'in_progress') {
134 return { state: 'running', label: STATE_WORD.running }
135 }
136
137 if (status === 'queued' || status === 'requested' || status === 'waiting' || status === 'pending') {
138 return { state: 'pending', label: status === 'waiting' ? 'Waiting' : status === 'queued' ? 'Queued' : STATE_WORD.pending }
139 }
140
141 return { state: 'unknown', label: STATE_WORD.unknown }
142 }
143
144 switch (conclusion) {
145 case 'success':
146 return { state: 'passed', label: STATE_WORD.passed }
147 case 'failure':
148 return { state: 'failed', label: STATE_WORD.failed }
149 case 'timed_out':
150 return { state: 'failed', label: 'Timed out' }
151 case 'startup_failure':
152 return { state: 'failed', label: 'Startup failure' }
153 case 'cancelled':
154 return { state: 'cancelled', label: STATE_WORD.cancelled }
155 case 'skipped':
156 return { state: 'skipped', label: STATE_WORD.skipped }
157 case 'neutral':
158 return { state: 'skipped', label: 'Neutral' }
159 case 'action_required':
160 return { state: 'blocked', label: 'Action required' }
161 case 'stale':
162 return { state: 'unknown', label: 'Stale' }
163 default:
164 return { state: 'unknown', label: STATE_WORD.unknown }
165 }
166}
167
168export function mapCommitStatus(state: string): { state: MergeWatchCiState; label: string } {
169 switch (state) {
170 case 'success':
171 return { state: 'passed', label: STATE_WORD.passed }
172 case 'failure':
173 return { state: 'failed', label: STATE_WORD.failed }
174 case 'error':
175 return { state: 'failed', label: 'Error' }
176 case 'pending':
177 return { state: 'pending', label: STATE_WORD.pending }
178 default:
179 return { state: 'unknown', label: STATE_WORD.unknown }
180 }
181}
182
183/** Latest review per reviewer decides; comments alone do not change a decision. */
184export function githubReview(detail: Json, reviews: readonly Json[] | null): string {
185 if (reviews === null) {
186 return 'Review status unavailable'
187 }
188
189 const latest = new Map<string, string>()
190
191 for (const review of reviews) {
192 const user = str(asRecord(review.user).login)
193 const state = str(review.state)
194
195 if (state === 'APPROVED' || state === 'CHANGES_REQUESTED' || state === 'DISMISSED') {
196 latest.set(user, state)
197 }
198 }
199
200 const states = [...latest.values()]
201
202 if (states.includes('CHANGES_REQUESTED')) {
203 return 'Changes requested'
204 }
205
206 const pending = Array.isArray(detail.requested_reviewers) ? detail.requested_reviewers.length : 0
207 const teams = Array.isArray(detail.requested_teams) ? detail.requested_teams.length : 0
208
209 if (states.includes('APPROVED')) {
210 return pending + teams > 0 ? 'Approved, more reviews requested' : 'Approved'
211 }
212
213 return pending + teams > 0 ? 'Awaiting review' : 'No reviews yet'
214}
215
216export function githubReadiness(detail: Json): { readiness: string; blockers: string[] } {
217 const blockers: string[] = []
218 const state = str(detail.mergeable_state)
219
220 if (detail.draft === true) {
221 blockers.push('Draft')
222 }
223
224 if (detail.mergeable === false || state === 'dirty') {
225 blockers.push('Merge conflicts')
226 }
227
228 if (state === 'blocked') {
229 blockers.push('Blocked by branch protection or required reviews')
230 }
231
232 if (state === 'behind') {
233 blockers.push('Branch is behind the base branch')
234 }
235
236 if (detail.mergeable === null || detail.mergeable === undefined || state === 'unknown' || state === '') {
237 return { readiness: 'Checking mergeability', blockers }
238 }
239
240 if (blockers.length > 0) {
241 return { readiness: 'Not ready to merge', blockers }
242 }
243
244 if (state === 'clean' || state === 'has_hooks') {
245 return { readiness: 'Ready to merge', blockers }
246 }
247
248 if (state === 'unstable') {
249 return { readiness: 'Mergeable, but some checks are not passing', blockers }
250 }
251
252 return { readiness: 'Unknown', blockers }
253}
254
255/**
256 * Keeps workflow runs that verifiably belong to this PR's current head: same commit, and
257 * either GitHub lists the PR on the run or the run came from the PR's own head repository.
258 * A run is never picked just because its branch name matches. Re-runs keep only the newest.
259 */
260export function relevantRuns(runs: readonly Json[], pr: { number: number; headSha: string; headRepo: string | null; baseRepo: string }): Json[] {
261 const kept = new Map<string, Json>()
262
263 for (const run of runs) {
264 if (str(run.head_sha) !== pr.headSha || !RELEVANT_EVENTS.has(str(run.event))) {
265 continue
266 }
267
268 const listed = Array.isArray(run.pull_requests) && run.pull_requests.some(p => Number(asRecord(p).number) === pr.number)
269 const runHeadRepo = str(asRecord(run.head_repository).full_name)
270 const fromHeadRepo = pr.headRepo !== null && runHeadRepo === pr.headRepo
271
272 if (!listed && !fromHeadRepo) {
273 continue
274 }
275
276 const key = `${str(run.workflow_id)}:${str(run.event)}`
277 const seen = kept.get(key)
278 const newer =
279 seen === undefined ||
280 Number(run.run_number) > Number(seen.run_number) ||
281 (Number(run.run_number) === Number(seen.run_number) && Number(run.run_attempt) > Number(seen.run_attempt))
282
283 if (newer) {
284 kept.set(key, run)
285 }
286 }
287
288 return [...kept.values()].sort((a, b) => str(a.name).localeCompare(str(b.name)))
289}
290
291function jobFromCheck(raw: Json, preferDetails: boolean): MergeWatchJob {
292 const { state, label } = mapCheckState(str(raw.status), str(raw.conclusion))
293 const url = preferDetails ? safeUrl(raw.details_url) ?? safeUrl(raw.html_url) : safeUrl(raw.html_url) ?? safeUrl(raw.details_url)
294
295 return job(str(raw.id), cleanText(raw.name) || 'Unnamed check', state, { label, url })
296}
297
298async function loadCi(ctx: Ctx, pr: { number: number; headSha: string; headRepo: string | null; baseRepo: string }): Promise<MergeWatchPipeline[]> {
299 const repoApi = `${apiBase(ctx.repo.host)}/repos/${ctx.repo.path}`
300 const sha = encodeURIComponent(pr.headSha)
301 const pipelines: MergeWatchPipeline[] = []
302 const jobIds = new Set<string>()
303 let runsFailed: string | null = null
304
305 try {
306 const runs = await getAll(ctx, `${repoApi}/actions/runs?head_sha=${sha}&per_page=100`, 'workflow_runs')
307
308 for (const run of relevantRuns(runs, pr)) {
309 const mapped = mapCheckState(str(run.status), str(run.conclusion))
310 const pipeline: MergeWatchPipeline = {
311 title: `${cleanText(run.name) || 'Workflow'} (${cleanText(run.event)})`,
312 ...mapped,
313 url: safeUrl(run.html_url),
314 isPreviousRevision: false,
315 isIncomplete: false,
316 notes: Number(run.run_attempt) > 1 ? [`Attempt ${Number(run.run_attempt)}`] : [],
317 jobs: [],
318 children: [],
319 projectId: null,
320 runId: str(run.id) || null,
321 }
322
323 try {
324 const jobs = await getAll(ctx, `${repoApi}/actions/runs/${str(run.id)}/jobs?filter=latest&per_page=100`, 'jobs')
325
326 for (const j of jobs) {
327 jobIds.add(str(j.id))
328 }
329
330 pipeline.jobs = jobs.map(j => jobFromCheck(j, false))
331 } catch (error) {
332 if (error instanceof AuthError || error instanceof RateLimitError) {
333 throw error
334 }
335
336 pipeline.isIncomplete = true
337 pipeline.notes.push(`Jobs unavailable: ${cleanError(error)}`)
338 }
339
340 pipelines.push(pipeline)
341 }
342 } catch (error) {
343 if (error instanceof AuthError || error instanceof RateLimitError) {
344 throw error
345 }
346
347 runsFailed = cleanError(error)
348 }
349
350 try {
351 const checks = await getAll(ctx, `${repoApi}/commits/${sha}/check-runs?filter=latest&per_page=100`, 'check_runs')
352 // An Actions job is also a check run with the same id: show it once, under its workflow.
353 const extra = checks.filter(c => !jobIds.has(str(c.id)))
354
355 if (extra.length > 0) {
356 const jobs = extra.map(c => jobFromCheck(c, true))
357 const state = rollupJobs(jobs)
358 pipelines.push({
359 title: 'Checks',
360 state,
361 label: STATE_WORD[state],
362 url: null,
363 isPreviousRevision: false,
364 isIncomplete: runsFailed !== null,
365 notes: runsFailed === null ? [] : [`Workflow runs unavailable: ${runsFailed}`],
366 jobs,
367 children: [],
368 projectId: null,
369 runId: null,
370 })
371 } else if (runsFailed !== null) {
372 pipelines.push(unavailableGroup('Workflow runs', runsFailed))
373 }
374 } catch (error) {
375 if (error instanceof AuthError || error instanceof RateLimitError) {
376 throw error
377 }
378
379 pipelines.push(unavailableGroup('Checks', cleanError(error)))
380 }
381
382 try {
383 const combined = asRecord(await getJson(ctx, `${repoApi}/commits/${sha}/status`))
384 const statuses = Array.isArray(combined.statuses) ? combined.statuses.map(asRecord) : []
385
386 if (statuses.length > 0) {
387 const jobs = statuses.map(s => {
388 const mapped = mapCommitStatus(str(s.state))
389
390 return job(str(s.id) || str(s.context), cleanText(s.context) || 'Status', mapped.state, {
391 label: mapped.label,
392 url: safeUrl(s.target_url),
393 })
394 })
395 const state = rollupJobs(jobs)
396 pipelines.push({ title: 'Commit statuses', state, label: STATE_WORD[state], url: null, isPreviousRevision: false, isIncomplete: false, notes: [], jobs, children: [], projectId: null, runId: null })
397 }
398 } catch (error) {
399 if (error instanceof AuthError || error instanceof RateLimitError) {
400 throw error
401 }
402
403 pipelines.push(unavailableGroup('Commit statuses', cleanError(error)))
404 }
405
406 return pipelines
407}
408
409function unavailableGroup(title: string, reason: string): MergeWatchPipeline {
410 return { title, state: 'unavailable', label: 'Unavailable', url: null, isPreviousRevision: false, isIncomplete: true, notes: [reason], jobs: [], children: [], projectId: null, runId: null }
411}
412
413async function loadRequest(ctx: Ctx, listed: Json): Promise<MergeWatchRequest> {
414 const number = Number(listed.number)
415 const repoApi = `${apiBase(ctx.repo.host)}/repos/${ctx.repo.path}`
416 const detail = { ...listed, ...asRecord(await getJson(ctx, `${repoApi}/pulls/${number}`)) }
417 let reviews: Json[] | null = null
418
419 try {
420 reviews = await getAll(ctx, `${repoApi}/pulls/${number}/reviews?per_page=100`)
421 } catch (error) {
422 if (error instanceof AuthError || error instanceof RateLimitError) {
423 throw error
424 }
425 }
426
427 const head = asRecord(detail.head)
428 const base = asRecord(detail.base)
429 const headRepo = str(asRecord(head.repo).full_name) || null
430 const baseRepo = str(asRecord(base.repo).full_name) || ctx.repo.path
431 const pipelines = await loadCi(ctx, { number, headSha: str(head.sha), headRepo, baseRepo })
432 const state = pipelines.length === 0 ? 'no-checks' : rollup(pipelines)
433 const { readiness, blockers } = githubReadiness(detail)
434 const review = githubReview(detail, reviews)
435 const mergeState = str(detail.mergeable_state)
436
437 return {
438 key: String(number),
439 number,
440 ref: `#${number}`,
441 title: cleanText(detail.title) || '(untitled)',
442 url: safeUrl(detail.html_url),
443 author: cleanText(asRecord(detail.user).login) || 'unknown',
444 sourceBranch: cleanText(head.ref, 120),
445 targetBranch: cleanText(base.ref, 120),
446 sourceProject: headRepo === null ? 'deleted fork' : headRepo.toLowerCase() === baseRepo.toLowerCase() ? null : cleanText(headRepo),
447 isDraft: detail.draft === true,
448 review,
449 readiness,
450 blockers,
451 ci: { state, label: pipelines.length === 0 ? 'No checks' : ciLabel(state, pipelines), pipelines },
452 updatedAt: str(detail.updated_at),
453 headSha: str(head.sha),
454 canMerge: detail.mergeable === true && detail.draft !== true && (mergeState === 'clean' || mergeState === 'has_hooks'),
455 needsReview: review === 'Awaiting review',
456 error: null,
457 staleSince: null,
458 }
459}
460
461function unavailableRequest(listed: Json, error: unknown): MergeWatchRequest {
462 const number = Number(listed.number)
463
464 return {
465 key: String(number),
466 number,
467 ref: `#${number}`,
468 title: cleanText(listed.title) || '(untitled)',
469 url: safeUrl(listed.html_url),
470 author: cleanText(asRecord(listed.user).login) || 'unknown',
471 sourceBranch: cleanText(asRecord(listed.head).ref, 120),
472 targetBranch: cleanText(asRecord(listed.base).ref, 120),
473 sourceProject: null,
474 isDraft: listed.draft === true,
475 review: 'Review status unavailable',
476 readiness: 'Unknown',
477 blockers: [],
478 ci: { state: 'unavailable', label: 'Unavailable', pipelines: [] },
479 updatedAt: str(listed.updated_at),
480 headSha: str(asRecord(listed.head).sha),
481 canMerge: false,
482 needsReview: false,
483 error: cleanError(error),
484 staleSince: null,
485 }
486}
487
488/** Every open PR (all pages) with its reviews, mergeability, workflows, checks and statuses. */
489export async function fetchGitHub(repo: MergeWatchRepo, get: HttpGet, token: string | undefined, now: number): Promise<MergeWatchRequest[]> {
490 if (token === undefined || token.trim() === '') {
491 throw new AuthError(`No GitHub token for ${repo.host}`, authHelp(repo.host))
492 }
493
494 const ctx: Ctx = { repo, get, token: token.trim(), now }
495 const listed = await getAll(ctx, `${apiBase(repo.host)}/repos/${repo.path}/pulls?state=open&sort=updated&direction=desc&per_page=100`)
496
497 return mapLimit(listed, PR_CONCURRENCY, async pr => {
498 try {
499 return await loadRequest(ctx, pr)
500 } catch (error) {
501 if (error instanceof AuthError || error instanceof RateLimitError) {
502 throw error
503 }
504
505 return unavailableRequest(pr, error)
506 }
507 })
508}
509
510// ---------------------------------------------------------------------------
511// Write actions. Only ever run after the person confirms in the pane.
512// ---------------------------------------------------------------------------
513
514async function write(repo: MergeWatchRepo, send: HttpSend, token: string | undefined, now: number, url: string, method: string, body?: unknown): Promise<void> {
515 if (token === undefined || token.trim() === '') {
516 throw new AuthError(`No GitHub token for ${repo.host}`, authHelp(repo.host))
517 }
518
519 const init = {
520 method,
521 headers: {
522 accept: 'application/vnd.github+json',
523 authorization: `Bearer ${token.trim()}`,
524 'x-github-api-version': '2022-11-28',
525 'user-agent': 'merge-watch-claude-code-mod',
526 ...(body === undefined ? {} : { 'content-type': 'application/json' }),
527 },
528 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
529 }
530 const response = await send(url, init)
531
532 if (response.status === 401) {
533 throw new AuthError(`GitHub rejected the token for ${repo.host}`, authHelp(repo.host))
534 }
535
536 if (response.status === 429 || (response.status === 403 && response.headers['x-ratelimit-remaining'] === '0')) {
537 throw new RateLimitError(`GitHub rate limit reached for ${repo.host}`, now + 60_000)
538 }
539
540 if (!response.ok) {
541 let message = ''
542
543 try {
544 message = str(asRecord(JSON.parse(response.text)).message)
545 } catch {
546 message = ''
547 }
548
549 throw new Error(`GitHub answered ${response.status}${message === '' ? '' : `: ${cleanText(message, 160)}`}`)
550 }
551}
552
553/** Re-runs the failed jobs of every workflow run that has a failure that is not allowed. */
554export async function retryFailedGitHub(repo: MergeWatchRepo, send: HttpSend, token: string | undefined, request: MergeWatchRequest, now: number): Promise<number> {
555 let count = 0
556
557 for (const pipeline of request.ci.pipelines) {
558 if (!pipeline.runId || !pipeline.jobs.some(j => j.state === 'failed' && !j.isAllowedFailure)) {
559 continue
560 }
561
562 await write(repo, send, token, now, `${apiBase(repo.host)}/repos/${repo.path}/actions/runs/${pipeline.runId}/rerun-failed-jobs`, 'POST')
563 count += pipeline.jobs.filter(j => j.state === 'failed' && !j.isAllowedFailure).length
564 }
565
566 return count
567}
568
569/** Merges the PR, pinned to the head commit the pane showed. */
570export async function mergeGitHub(repo: MergeWatchRepo, send: HttpSend, token: string | undefined, request: MergeWatchRequest, now: number): Promise<void> {
571 await write(repo, send, token, now, `${apiBase(repo.host)}/repos/${repo.path}/pulls/${request.number}/merge`, 'PUT', request.headSha === '' ? {} : { sha: request.headSha })
572}
573hooks/gitlab.ts 582 lines1// GitLab adapter: `glab api` GET requests mapped to the shared display model, plus the two
2// write actions the pane offers after a confirm press: retry failed jobs and merge.
3
4import type { MergeWatchCiState, MergeWatchJob, MergeWatchPipeline, MergeWatchRepo, MergeWatchRequest } from '../types'
5import { cleanError, cleanText, safeUrl } from './safe'
6import { AuthError, RateLimitError, STATE_WORD, ciLabel, job, mapLimit, rollup } from './status'
7
8/** Runs `glab` with an argument vector (never a shell string) and resolves its stdout; rejects with stderr. */
9export type GlabRunner = (args: readonly string[]) => Promise<string>
10
11type Json = Record<string, unknown>
12
13const MR_CONCURRENCY = 4
14const RATE_LIMIT_BACKOFF_MS = 5 * 60_000
15
16function asRecord(value: unknown): Json {
17 return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {}
18}
19
20function str(value: unknown): string {
21 return typeof value === 'string' ? value : typeof value === 'number' ? String(value) : ''
22}
23
24function apiArgs(host: string, path: string, paginate: boolean): string[] {
25 const args = ['api', '--hostname', host, '--method', 'GET', path]
26
27 return paginate ? [...args, '--paginate', '--output', 'ndjson'] : args
28}
29
30/** Turns a glab failure into an auth, rate-limit or plain error. */
31export function classifyGlabError(error: unknown, host: string, now: number): Error {
32 const text = cleanError(error)
33
34 if (/\b401\b|unauthori[sz]ed|not logged in|no token|authentication|glab auth login/i.test(text)) {
35 return new AuthError(
36 `GitLab sign-in missing for ${host}`,
37 `Sign in to ${host} with the GitLab CLI in your own terminal: glab auth login --hostname ${host}. Then run /merge-watch refresh.`,
38 )
39 }
40
41 if (/\b429\b|too many requests|rate limit/i.test(text)) {
42 return new RateLimitError(`GitLab is rate limiting requests from ${host}`, now + RATE_LIMIT_BACKOFF_MS)
43 }
44
45 if (/ENOENT|not found: glab|command not found|cannot start|executable/i.test(text)) {
46 return new AuthError(
47 'The GitLab CLI (glab) is not installed',
48 'Install the GitLab CLI (glab), sign in with glab auth login --hostname <your GitLab host>, then run /merge-watch refresh.',
49 )
50 }
51
52 return new Error(text)
53}
54
55export function parseNdjson(text: string): Json[] {
56 const out: Json[] = []
57
58 for (const line of text.split('\n')) {
59 const trimmed = line.trim()
60
61 if (trimmed === '') {
62 continue
63 }
64
65 const value: unknown = JSON.parse(trimmed)
66
67 // Some glab versions print a page as one array per line.
68 if (Array.isArray(value)) {
69 out.push(...value.map(asRecord))
70 } else {
71 out.push(asRecord(value))
72 }
73 }
74
75 return out
76}
77
78/** One GitLab job status → shared state and its word. */
79export function mapGitLabJob(raw: Json): MergeWatchJob {
80 const status = str(raw.status)
81 const allowFailure = raw.allow_failure === true
82 const url = safeUrl(raw.web_url)
83 const name = cleanText(raw.name) || 'Unnamed job'
84 const stage = cleanText(raw.stage) || null
85 const id = str(raw.id)
86
87 switch (status) {
88 case 'success':
89 return job(id, name, 'passed', { url, stage })
90 case 'failed':
91 return allowFailure
92 ? job(id, name, 'failed', { label: 'Failed (allowed)', isAllowedFailure: true, url, stage })
93 : job(id, name, 'failed', { url, stage })
94 case 'running':
95 return job(id, name, 'running', { url, stage })
96 case 'pending':
97 case 'created':
98 case 'preparing':
99 case 'waiting_for_resource':
100 case 'waiting_for_callback':
101 return job(id, name, 'pending', { url, stage })
102 case 'scheduled':
103 return job(id, name, 'pending', { label: 'Scheduled', url, stage })
104 case 'manual':
105 return job(id, name, 'manual', {
106 label: allowFailure ? 'Manual (optional)' : 'Manual (blocking)',
107 url,
108 stage,
109 })
110 case 'skipped':
111 return job(id, name, 'skipped', { url, stage })
112 case 'canceled':
113 case 'canceling':
114 return job(id, name, 'cancelled', { url, stage })
115 default:
116 return job(id, name, 'unknown', { label: status === '' ? 'Unknown' : `Unknown (${cleanText(status, 30)})`, url, stage })
117 }
118}
119
120/** A GitLab pipeline status → shared state. `manual` pipelines wait on a manual job and are Blocked. */
121export function mapGitLabPipelineState(raw: Json): { state: MergeWatchCiState; label: string } {
122 const status = str(raw.status)
123 const group = str(asRecord(raw.detailed_status).group)
124
125 switch (status) {
126 case 'success':
127 return group === 'success-with-warnings'
128 ? { state: 'warning', label: STATE_WORD.warning }
129 : { state: 'passed', label: STATE_WORD.passed }
130 case 'failed':
131 return { state: 'failed', label: STATE_WORD.failed }
132 case 'running':
133 return { state: 'running', label: STATE_WORD.running }
134 case 'pending':
135 case 'created':
136 case 'preparing':
137 case 'waiting_for_resource':
138 case 'waiting_for_callback':
139 return { state: 'pending', label: STATE_WORD.pending }
140 case 'scheduled':
141 return { state: 'pending', label: 'Scheduled' }
142 case 'manual':
143 return { state: 'blocked', label: 'Blocked (waiting for a manual job)' }
144 case 'skipped':
145 return { state: 'skipped', label: STATE_WORD.skipped }
146 case 'canceled':
147 case 'canceling':
148 return { state: 'cancelled', label: STATE_WORD.cancelled }
149 default:
150 return { state: 'unknown', label: STATE_WORD.unknown }
151 }
152}
153
154/** Keeps the newest attempt of each job name, so an older failed try never replaces a retry. */
155export function latestAttempts(jobs: readonly Json[]): Json[] {
156 const byName = new Map<string, Json>()
157
158 for (const j of jobs) {
159 const name = str(j.name)
160 const seen = byName.get(name)
161
162 if (seen === undefined || Number(j.id) > Number(seen.id)) {
163 byName.set(name, j)
164 }
165 }
166
167 return [...byName.values()].sort((a, b) => Number(a.id) - Number(b.id))
168}
169
170const MERGE_STATUS_BLOCKERS: Record<string, string> = {
171 conflict: 'Merge conflicts',
172 discussions_not_resolved: 'Unresolved discussions',
173 need_rebase: 'Needs rebase',
174 blocked_status: 'Blocked by another merge request',
175 not_approved: 'Needs approval',
176 ci_must_pass: 'Pipeline must pass',
177 ci_still_running: 'Pipeline still running',
178 draft_status: 'Draft',
179 requested_changes: 'Changes requested',
180 external_status_checks: 'External status checks pending',
181 jira_association_missing: 'Jira issue missing',
182 merge_time: 'Scheduled merge time not reached',
183 security_policy_violations: 'Security policy violation',
184 security_policy_pipeline_check: 'Security policy pipeline check',
185 title_regex: 'Title does not match the required format',
186 locked_paths: 'Locked paths',
187 locked_lfs_files: 'Locked LFS files',
188 commits_status: 'Commit status checks',
189 not_open: 'Not open',
190}
191
192const CHECKING = new Set(['checking', 'unchecked', 'preparing', 'approvals_syncing', 'cannot_be_merged_recheck'])
193
194export function gitlabReadiness(detail: Json): { readiness: string; blockers: string[] } {
195 const status = str(detail.detailed_merge_status)
196 const blockers: string[] = []
197
198 if (detail.has_conflicts === true || status === 'conflict') {
199 blockers.push('Merge conflicts')
200 }
201
202 const known = MERGE_STATUS_BLOCKERS[status]
203
204 if (known !== undefined && !blockers.includes(known) && status !== 'draft_status') {
205 blockers.push(known)
206 } else if (known === undefined && status !== '' && status !== 'mergeable' && !CHECKING.has(status)) {
207 blockers.push(cleanText(status.replace(/_/g, ' '), 60))
208 }
209
210 if (status === 'mergeable' && blockers.length === 0) {
211 return { readiness: 'Ready to merge', blockers }
212 }
213
214 if (CHECKING.has(status) || status === '') {
215 return { readiness: 'Checking mergeability', blockers }
216 }
217
218 return { readiness: 'Not ready to merge', blockers }
219}
220
221export function gitlabReview(detail: Json, approvals: Json | null): string {
222 if (str(detail.detailed_merge_status) === 'requested_changes') {
223 return 'Changes requested'
224 }
225
226 if (approvals === null) {
227 return 'Review status unavailable'
228 }
229
230 const left = Number(approvals.approvals_left ?? 0)
231 const approvedBy = Array.isArray(approvals.approved_by) ? approvals.approved_by.length : 0
232 const reviewers = Array.isArray(detail.reviewers) ? detail.reviewers.length : 0
233
234 if (left > 0) {
235 return `Awaiting review (${left} approval${left === 1 ? '' : 's'} needed)`
236 }
237
238 if (approvedBy > 0) {
239 return 'Approved'
240 }
241
242 return reviewers > 0 ? 'Awaiting review' : 'No approval required'
243}
244
245export type Ctx = { repo: MergeWatchRepo; glab: GlabRunner; now: number }
246
247async function getJson(ctx: Ctx, path: string): Promise<Json> {
248 let text: string
249
250 try {
251 text = await ctx.glab(apiArgs(ctx.repo.host, path, false))
252 } catch (error) {
253 throw classifyGlabError(error, ctx.repo.host, ctx.now)
254 }
255
256 return asRecord(JSON.parse(text))
257}
258
259async function getAll(ctx: Ctx, path: string): Promise<Json[]> {
260 let text: string
261
262 try {
263 text = await ctx.glab(apiArgs(ctx.repo.host, path, true))
264 } catch (error) {
265 throw classifyGlabError(error, ctx.repo.host, ctx.now)
266 }
267
268 return parseNdjson(text)
269}
270
271/** Loads one pipeline's jobs (newest attempts) and its downstream pipelines, one level deep. */
272async function loadPipeline(ctx: Ctx, raw: Json, depth: number): Promise<MergeWatchPipeline> {
273 const projectId = str(raw.project_id)
274 const id = str(raw.id)
275 const { state, label } = mapGitLabPipelineState(raw)
276 const pipeline: MergeWatchPipeline = {
277 title: depth === 0 ? `Pipeline #${id}` : `Downstream pipeline #${id}`,
278 state,
279 label,
280 url: safeUrl(raw.web_url),
281 isPreviousRevision: false,
282 isIncomplete: false,
283 notes: [],
284 jobs: [],
285 children: [],
286 projectId: projectId === '' ? null : projectId,
287 runId: null,
288 }
289
290 try {
291 const jobs = await getAll(ctx, `projects/${projectId}/pipelines/${id}/jobs?per_page=100`)
292 pipeline.jobs = latestAttempts(jobs).map(mapGitLabJob)
293 } catch (error) {
294 if (error instanceof AuthError || error instanceof RateLimitError) {
295 throw error
296 }
297
298 pipeline.isIncomplete = true
299 pipeline.notes.push(`Jobs unavailable: ${cleanError(error)}`)
300 }
301
302 let bridges: Json[] = []
303
304 try {
305 bridges = await getAll(ctx, `projects/${projectId}/pipelines/${id}/bridges?per_page=100`)
306 } catch (error) {
307 if (error instanceof AuthError || error instanceof RateLimitError) {
308 throw error
309 }
310
311 pipeline.isIncomplete = true
312 pipeline.notes.push('Downstream pipelines unavailable')
313 }
314
315 for (const bridge of latestAttempts(bridges)) {
316 const downstream = asRecord(bridge.downstream_pipeline)
317
318 if (str(downstream.id) === '') {
319 continue
320 }
321
322 if (depth >= 1) {
323 const mapped = mapGitLabPipelineState(downstream)
324 pipeline.children.push({
325 title: `Downstream pipeline #${str(downstream.id)} (${cleanText(bridge.name)})`,
326 ...mapped,
327 url: safeUrl(downstream.web_url),
328 isPreviousRevision: false,
329 isIncomplete: true,
330 notes: ['Not loaded: nested deeper than one level'],
331 jobs: [],
332 children: [],
333 projectId: str(downstream.project_id) || null,
334 runId: null,
335 })
336 continue
337 }
338
339 try {
340 // The downstream pipeline may live in another project; fetch it from its own.
341 const full = await getJson(ctx, `projects/${str(downstream.project_id)}/pipelines/${str(downstream.id)}`)
342 const child = await loadPipeline(ctx, full, depth + 1)
343 child.title = `Downstream pipeline #${str(downstream.id)} (${cleanText(bridge.name)})`
344 pipeline.children.push(child)
345 } catch (error) {
346 if (error instanceof AuthError || error instanceof RateLimitError) {
347 throw error
348 }
349
350 pipeline.isIncomplete = true
351 pipeline.children.push({
352 title: `Downstream pipeline #${str(downstream.id)} (${cleanText(bridge.name)})`,
353 state: 'unavailable',
354 label: 'Not accessible',
355 url: safeUrl(downstream.web_url),
356 isPreviousRevision: false,
357 isIncomplete: true,
358 notes: ['Not accessible with your GitLab access'],
359 jobs: [],
360 children: [],
361 projectId: null,
362 runId: null,
363 })
364 }
365 }
366
367 return pipeline
368}
369
370/**
371 * Decides whether the MR's head pipeline ran for the MR's current commit. A merged-result
372 * pipeline runs on a merge commit, so it counts as current when that commit's parents
373 * include the MR's head. When that cannot be checked the answer is "unconfirmed", never current.
374 */
375export async function revisionOf(
376 ctx: Ctx,
377 pipeline: Json,
378 mrSha: string,
379): Promise<{ revision: 'current' | 'previous' | 'unconfirmed'; note: string | null }> {
380 const sha = str(pipeline.sha)
381 const ref = str(pipeline.ref)
382
383 if (mrSha === '' || sha === '') {
384 return { revision: 'unconfirmed', note: 'Could not confirm which revision this pipeline tested' }
385 }
386
387 if (sha === mrSha) {
388 return { revision: 'current', note: null }
389 }
390
391 if (/^refs\/merge-requests\/\d+\/(merge|train)$/.test(ref)) {
392 try {
393 const commit = await getJson(ctx, `projects/${str(pipeline.project_id)}/repository/commits/${encodeURIComponent(sha)}`)
394 const parents = Array.isArray(commit.parent_ids) ? commit.parent_ids.map(str) : []
395
396 return parents.includes(mrSha)
397 ? { revision: 'current', note: ref.endsWith('/train') ? 'Merge train pipeline' : 'Merged result pipeline' }
398 : { revision: 'previous', note: null }
399 } catch (error) {
400 if (error instanceof AuthError || error instanceof RateLimitError) {
401 throw error
402 }
403
404 return { revision: 'unconfirmed', note: 'Could not confirm which revision this pipeline tested' }
405 }
406 }
407
408 return { revision: 'previous', note: null }
409}
410
411async function loadRequest(ctx: Ctx, listed: Json): Promise<MergeWatchRequest> {
412 const iid = str(listed.iid)
413 const base = `projects/${encodeURIComponent(ctx.repo.path)}/merge_requests/${iid}`
414 const detail = { ...listed, ...(await getJson(ctx, base)) }
415 let approvals: Json | null = null
416
417 try {
418 approvals = await getJson(ctx, `${base}/approvals`)
419 } catch (error) {
420 if (error instanceof AuthError || error instanceof RateLimitError) {
421 throw error
422 }
423 }
424
425 let sourceProject: string | null = null
426
427 if (str(detail.source_project_id) !== '' && str(detail.source_project_id) !== str(detail.target_project_id)) {
428 try {
429 const project = await getJson(ctx, `projects/${str(detail.source_project_id)}`)
430 sourceProject = cleanText(project.path_with_namespace) || `project ${str(detail.source_project_id)}`
431 } catch {
432 sourceProject = `project ${str(detail.source_project_id)}`
433 }
434 }
435
436 const head = asRecord(detail.head_pipeline)
437 const pipelines: MergeWatchPipeline[] = []
438
439 if (str(head.id) !== '') {
440 const pipeline = await loadPipeline(ctx, head, 0)
441 const mrSha = str(asRecord(detail.diff_refs).head_sha) || str(detail.sha)
442 const revision = await revisionOf(ctx, head, mrSha)
443
444 if (revision.revision === 'previous') {
445 pipeline.isPreviousRevision = true
446 pipeline.notes.unshift('Previous revision: this pipeline ran for an older commit')
447 } else if (revision.revision === 'unconfirmed') {
448 pipeline.isIncomplete = true
449 }
450
451 if (revision.note !== null) {
452 pipeline.notes.push(revision.note)
453 }
454
455 pipelines.push(pipeline)
456 }
457
458 const state = pipelines.length === 0 ? 'no-pipeline' : rollup(pipelines)
459 const { readiness, blockers } = gitlabReadiness(detail)
460 const author = asRecord(detail.author)
461 const review = gitlabReview(detail, approvals)
462
463 return {
464 key: iid,
465 number: Number(iid),
466 ref: `!${iid}`,
467 title: cleanText(detail.title) || '(untitled)',
468 url: safeUrl(detail.web_url),
469 author: cleanText(author.username) || cleanText(author.name) || 'unknown',
470 sourceBranch: cleanText(detail.source_branch, 120),
471 targetBranch: cleanText(detail.target_branch, 120),
472 sourceProject,
473 isDraft: detail.draft === true || detail.work_in_progress === true,
474 review,
475 readiness,
476 blockers,
477 ci: { state, label: pipelines.length === 0 ? 'No pipeline' : ciLabel(state, pipelines), pipelines },
478 updatedAt: str(detail.updated_at),
479 headSha: str(asRecord(detail.diff_refs).head_sha) || str(detail.sha),
480 canMerge: str(detail.detailed_merge_status) === 'mergeable' && asRecord(detail.user).can_merge !== false,
481 needsReview: review.startsWith('Awaiting review'),
482 error: null,
483 staleSince: null,
484 }
485}
486
487/** A request whose details could not be loaded: listed facts only, CI Unavailable. */
488function unavailableRequest(listed: Json, error: unknown): MergeWatchRequest {
489 const iid = str(listed.iid)
490 const author = asRecord(listed.author)
491
492 return {
493 key: iid,
494 number: Number(iid),
495 ref: `!${iid}`,
496 title: cleanText(listed.title) || '(untitled)',
497 url: safeUrl(listed.web_url),
498 author: cleanText(author.username) || 'unknown',
499 sourceBranch: cleanText(listed.source_branch, 120),
500 targetBranch: cleanText(listed.target_branch, 120),
501 sourceProject: null,
502 isDraft: listed.draft === true,
503 review: 'Review status unavailable',
504 readiness: 'Unknown',
505 blockers: [],
506 ci: { state: 'unavailable', label: 'Unavailable', pipelines: [] },
507 updatedAt: str(listed.updated_at),
508 headSha: str(listed.sha),
509 canMerge: false,
510 needsReview: false,
511 error: cleanError(error),
512 staleSince: null,
513 }
514}
515
516/** Every open MR (all pages), each with its own head pipeline and jobs. */
517export async function fetchGitLab(repo: MergeWatchRepo, glab: GlabRunner, now: number): Promise<MergeWatchRequest[]> {
518 const ctx: Ctx = { repo, glab, now }
519 const listed = await getAll(
520 ctx,
521 `projects/${encodeURIComponent(repo.path)}/merge_requests?state=opened&per_page=100&order_by=updated_at&sort=desc`,
522 )
523
524 return mapLimit(listed, MR_CONCURRENCY, async mr => {
525 try {
526 return await loadRequest(ctx, mr)
527 } catch (error) {
528 if (error instanceof AuthError || error instanceof RateLimitError) {
529 throw error
530 }
531
532 return unavailableRequest(mr, error)
533 }
534 })
535}
536
537// ---------------------------------------------------------------------------
538// Write actions. Only ever run after the person confirms in the pane.
539// ---------------------------------------------------------------------------
540
541function flatten(pipelines: readonly MergeWatchPipeline[]): MergeWatchPipeline[] {
542 return pipelines.flatMap(p => [p, ...flatten(p.children)])
543}
544
545/** Re-runs every failed job that is not allowed to fail, in the project that owns its pipeline. */
546export async function retryFailedGitLab(repo: MergeWatchRepo, glab: GlabRunner, request: MergeWatchRequest, now: number): Promise<number> {
547 let count = 0
548
549 for (const pipeline of flatten(request.ci.pipelines)) {
550 if (!pipeline.projectId || pipeline.isPreviousRevision) {
551 continue
552 }
553
554 for (const j of pipeline.jobs) {
555 if (j.state !== 'failed' || j.isAllowedFailure || !/^\d+$/.test(j.id)) {
556 continue
557 }
558
559 try {
560 await glab(['api', '--hostname', repo.host, '--method', 'POST', `projects/${pipeline.projectId}/jobs/${j.id}/retry`])
561 } catch (error) {
562 throw classifyGlabError(error, repo.host, now)
563 }
564
565 count += 1
566 }
567 }
568
569 return count
570}
571
572/** Merges the MR, pinned to the commit the pane showed: GitLab refuses if the branch moved since. */
573export async function mergeGitLab(repo: MergeWatchRepo, glab: GlabRunner, request: MergeWatchRequest, now: number): Promise<void> {
574 const args = ['api', '--hostname', repo.host, '--method', 'PUT', `projects/${encodeURIComponent(repo.path)}/merge_requests/${request.number}/merge`]
575
576 try {
577 await glab(request.headSha === '' ? args : [...args, '--raw-field', `sha=${request.headSha}`])
578 } catch (error) {
579 throw classifyGlabError(error, repo.host, now)
580 }
581}
582hooks/repo.ts 165 lines1// Repository detection: parse git remotes and choose which hosted repository to watch.
2
3import type { MergeWatchProvider, MergeWatchRepo } from '../types'
4
5export type ParsedRemote = { host: string; path: string }
6
7export type HostConfig = {
8 /** Extra GitHub Enterprise hosts, e.g. "github.example.com". */
9 githubHosts: readonly string[]
10 /** Extra self-hosted GitLab hosts that do not have "gitlab" in their name. */
11 gitlabHosts: readonly string[]
12}
13
14/**
15 * Parses HTTPS, SSH (`ssh://`) and scp-style (`git@host:group/sub/repo.git`) remote URLs.
16 * Credentials in the URL are dropped; nested groups are kept whole.
17 */
18export function parseRemoteUrl(raw: string): ParsedRemote | null {
19 const value = raw.trim()
20
21 if (value === '') {
22 return null
23 }
24
25 let host: string
26 let path: string
27 const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/\/)(.+)$/.exec(value)
28
29 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
30 let url: URL
31
32 try {
33 url = new URL(value)
34 } catch {
35 return null
36 }
37
38 if (!['https:', 'http:', 'ssh:', 'git:', 'git+ssh:', 'ssh+git:'].includes(url.protocol)) {
39 return null
40 }
41
42 host = url.hostname
43 path = decodeURIComponent(url.pathname)
44 } else if (scp !== null) {
45 host = scp[1]!
46 path = scp[2]!
47 } else {
48 return null
49 }
50
51 path = path.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/i, '')
52
53 // GitLab's SSH form can carry a leading "scm/" or a port-like segment; keep only real path parts.
54 const parts = path.split('/').filter(Boolean)
55
56 if (parts.length < 2 || parts.some(p => p === '.' || p === '..')) {
57 return null
58 }
59
60 return { host: host.toLowerCase(), path: parts.join('/') }
61}
62
63export function providerFor(host: string, config: HostConfig): MergeWatchProvider | null {
64 const h = host.toLowerCase()
65
66 if (h === 'github.com' || config.githubHosts.map(x => x.toLowerCase()).includes(h)) {
67 return 'github'
68 }
69
70 if (h === 'gitlab.com' || /(^|\.)gitlab\./.test(h) || config.gitlabHosts.map(x => x.toLowerCase()).includes(h)) {
71 return 'gitlab'
72 }
73
74 return null
75}
76
77export function repoKey(provider: MergeWatchProvider, host: string, path: string): string {
78 return `${provider}:${host.toLowerCase()}/${path}`
79}
80
81/** `git remote -v` output → one supported repository per remote name (fetch URL preferred). */
82export function reposFromRemotes(remoteOutput: string, config: HostConfig): MergeWatchRepo[] {
83 const byName = new Map<string, MergeWatchRepo>()
84
85 for (const line of remoteOutput.split('\n')) {
86 const match = /^(\S+)\s+(\S+)(?:\s+\((fetch|push)\))?\s*$/.exec(line.trim())
87
88 if (match === null) {
89 continue
90 }
91
92 const [, name, url, kind] = match
93
94 if (byName.has(name!) && kind === 'push') {
95 continue
96 }
97
98 const parsed = parseRemoteUrl(url!)
99
100 if (parsed === null) {
101 continue
102 }
103
104 const provider = providerFor(parsed.host, config)
105
106 if (provider === null) {
107 continue
108 }
109
110 byName.set(name!, {
111 provider,
112 host: parsed.host,
113 path: parsed.path,
114 key: repoKey(provider, parsed.host, parsed.path),
115 remoteName: name!,
116 webUrl: `https://${parsed.host}/${parsed.path}`,
117 })
118 }
119
120 return [...byName.values()]
121}
122
123export type RepoChoice =
124 | { kind: 'chosen'; repo: MergeWatchRepo; candidates: MergeWatchRepo[] }
125 | { kind: 'ambiguous'; candidates: MergeWatchRepo[] }
126 | { kind: 'none' }
127
128/** Saved selection, then `origin`, then a single supported remote; otherwise ask. */
129export function chooseRepo(candidates: readonly MergeWatchRepo[], savedKey: string | null): RepoChoice {
130 const list = [...candidates]
131
132 if (list.length === 0) {
133 return { kind: 'none' }
134 }
135
136 const saved = savedKey === null ? undefined : list.find(r => r.key === savedKey)
137
138 if (saved !== undefined) {
139 return { kind: 'chosen', repo: saved, candidates: list }
140 }
141
142 const origin = list.find(r => r.remoteName === 'origin')
143
144 if (origin !== undefined) {
145 return { kind: 'chosen', repo: origin, candidates: list }
146 }
147
148 const distinct = new Map(list.map(r => [r.key, r]))
149
150 if (distinct.size === 1) {
151 return { kind: 'chosen', repo: list[0]!, candidates: list }
152 }
153
154 return { kind: 'ambiguous', candidates: list }
155}
156
157export function splitHosts(value: unknown): string[] {
158 return typeof value === 'string'
159 ? value
160 .split(/[,\s]+/)
161 .map(h => h.trim().toLowerCase())
162 .filter(h => /^[a-z0-9.-]+(:\d+)?$/.test(h))
163 : []
164}
165hooks/safe.ts 60 lines1// Untrusted provider text and links: titles, authors, branch and job names are display data only.
2
3// ESC/CSI/OSC sequences, then any remaining C0/C1 control or bidi override character.
4const ESCAPE_SEQUENCE = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[PX^_][^\u001b]*\u001b\\|[@-Z\\-_])/g
5const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f--]/g
6
7/** One line of display text: control sequences stripped, whitespace collapsed, length capped. */
8export function cleanText(value: unknown, max = 300): string {
9 if (typeof value !== 'string' && typeof value !== 'number') {
10 return ''
11 }
12
13 const text = String(value)
14 .replace(ESCAPE_SEQUENCE, '')
15 .replace(CONTROL, ' ')
16 .replace(/\s+/g, ' ')
17 .trim()
18
19 return text.length > max ? `${text.slice(0, max - 1)}…` : text
20}
21
22/**
23 * A link the pane may draw: http(s) only, no embedded credentials, no control characters,
24 * at most 2048 characters. Anything else is null, and the pane says there is no link.
25 */
26export function safeUrl(value: unknown): string | null {
27 if (typeof value !== 'string' || value.length === 0 || value.length > 2048) {
28 return null
29 }
30
31 if (/[\u0000-\u001f\u007f-\u009f\s]/.test(value)) {
32 return null
33 }
34
35 let url: URL
36
37 try {
38 url = new URL(value)
39 } catch {
40 return null
41 }
42
43 if (url.protocol !== 'https:' && url.protocol !== 'http:') {
44 return null
45 }
46
47 if (url.username !== '' || url.password !== '' || url.hostname === '') {
48 return null
49 }
50
51 return url.toString()
52}
53
54/** Error text safe to show: one line, no control characters, no token-shaped values. */
55export function cleanError(error: unknown): string {
56 const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
57
58 return cleanText(raw.replace(/\b(?:glpat|ghp|gho|ghu|ghs|ghr|github_pat)[-_][A-Za-z0-9_-]+/g, '[redacted]'), 200)
59}
60hooks/status.ts 233 lines1// Shared status helpers: icons and words for every CI state, rollups, sorting, concurrency.
2
3import type { MergeWatchCiState, MergeWatchJob, MergeWatchPipeline, MergeWatchRequest } from '../types'
4
5/** Each state has an icon and a word, so status reads without colour. */
6export const STATE_ICON: Record<MergeWatchCiState, string> = {
7 passed: '✓',
8 warning: '⚠',
9 failed: '✕',
10 running: '▶',
11 pending: '○',
12 manual: '◆',
13 blocked: '⊘',
14 skipped: '–',
15 cancelled: '⊗',
16 'no-pipeline': '·',
17 'no-checks': '·',
18 unknown: '?',
19 unavailable: '!',
20}
21
22export const STATE_WORD: Record<MergeWatchCiState, string> = {
23 passed: 'Passed',
24 warning: 'Passed with warnings',
25 failed: 'Failed',
26 running: 'Running',
27 pending: 'Pending',
28 manual: 'Manual',
29 blocked: 'Blocked',
30 skipped: 'Skipped',
31 cancelled: 'Cancelled',
32 'no-pipeline': 'No pipeline',
33 'no-checks': 'No checks',
34 unknown: 'Unknown',
35 unavailable: 'Unavailable',
36}
37
38/** Theme colour per state; the icon and word carry the meaning on their own. */
39export const STATE_COLOR: Record<MergeWatchCiState, string | undefined> = {
40 passed: 'success',
41 warning: 'warning',
42 failed: 'error',
43 running: 'suggestion',
44 pending: undefined,
45 manual: 'permission',
46 blocked: 'warning',
47 skipped: undefined,
48 cancelled: undefined,
49 'no-pipeline': undefined,
50 'no-checks': undefined,
51 unknown: 'warning',
52 unavailable: 'warning',
53}
54
55// Higher wins when several results roll up into one.
56const SEVERITY: Record<MergeWatchCiState, number> = {
57 failed: 100,
58 unavailable: 90,
59 unknown: 85,
60 blocked: 80,
61 running: 70,
62 pending: 60,
63 manual: 50,
64 cancelled: 45,
65 warning: 30,
66 passed: 20,
67 skipped: 10,
68 'no-checks': 5,
69 'no-pipeline': 4,
70}
71
72export function job(
73 id: string,
74 name: string,
75 state: MergeWatchCiState,
76 options: { label?: string; isAllowedFailure?: boolean; url?: string | null; stage?: string | null } = {},
77): MergeWatchJob {
78 return {
79 id,
80 name,
81 state,
82 label: options.label ?? STATE_WORD[state],
83 isAllowedFailure: options.isAllowedFailure ?? false,
84 url: options.url ?? null,
85 stage: options.stage ?? null,
86 }
87}
88
89/**
90 * The overall state of several pipelines/check groups. A failed job that is allowed to fail
91 * does not fail the rollup, and nothing missing ever becomes Passed.
92 */
93export function rollup(pipelines: readonly MergeWatchPipeline[]): MergeWatchCiState {
94 if (pipelines.length === 0) {
95 return 'unknown'
96 }
97
98 let worst: MergeWatchCiState = pipelines[0]!.state
99
100 for (const p of pipelines) {
101 const state = p.isPreviousRevision ? 'unknown' : p.isIncomplete && p.state === 'passed' ? 'unknown' : p.state
102
103 if (SEVERITY[state] > SEVERITY[worst]) {
104 worst = state
105 }
106 }
107
108 return worst
109}
110
111/** Rolls jobs up into a state for a group that has no overall status of its own (GitHub checks). */
112export function rollupJobs(jobs: readonly MergeWatchJob[]): MergeWatchCiState {
113 if (jobs.length === 0) {
114 return 'no-checks'
115 }
116
117 let worst: MergeWatchCiState = 'skipped'
118 let hasAllowedFailure = false
119
120 for (const j of jobs) {
121 if (j.state === 'failed' && j.isAllowedFailure) {
122 hasAllowedFailure = true
123 continue
124 }
125
126 // A manual job that is optional does not hold the group.
127 const state: MergeWatchCiState = j.state === 'manual' && !j.label.includes('blocking') ? 'skipped' : j.state
128
129 if (SEVERITY[state] > SEVERITY[worst]) {
130 worst = state
131 }
132 }
133
134 if (worst === 'skipped' && jobs.every(j => j.state === 'skipped' || j.state === 'manual')) {
135 return 'skipped'
136 }
137
138 return hasAllowedFailure && worst === 'passed' ? 'warning' : worst
139}
140
141export function ciLabel(state: MergeWatchCiState, pipelines: readonly MergeWatchPipeline[]): string {
142 if (pipelines.some(p => p.isPreviousRevision) && pipelines.every(p => p.isPreviousRevision)) {
143 return 'Previous revision only'
144 }
145
146 return STATE_WORD[state]
147}
148
149/** Most recently updated first; the request number breaks ties so the order never flickers. */
150export function sortRequests(requests: readonly MergeWatchRequest[]): MergeWatchRequest[] {
151 return [...requests].sort((a, b) => {
152 const byTime = Date.parse(b.updatedAt) - Date.parse(a.updatedAt)
153
154 if (Number.isFinite(byTime) && byTime !== 0) {
155 return byTime
156 }
157
158 return b.number - a.number
159 })
160}
161
162/** Runs `fn` over `items` with at most `limit` in flight, keeping input order in the result. */
163export async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
164 const out = new Array<R>(items.length)
165 let cursor = 0
166
167 async function worker(): Promise<void> {
168 while (cursor < items.length) {
169 const index = cursor
170 cursor += 1
171 out[index] = await fn(items[index]!, index)
172 }
173 }
174
175 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()))
176
177 return out
178}
179
180/** Thrown by an adapter when the provider asks us to slow down. */
181export class RateLimitError extends Error {
182 constructor(
183 message: string,
184 readonly retryAt: number,
185 ) {
186 super(message)
187 this.name = 'RateLimitError'
188 }
189}
190
191/** Thrown by an adapter when no usable credential exists; `help` is shown as setup instructions. */
192export class AuthError extends Error {
193 constructor(
194 message: string,
195 readonly help: string,
196 ) {
197 super(message)
198 this.name = 'AuthError'
199 }
200}
201
202/**
203 * The jobs worth a row of their own: whatever is running, failed (allowed failures too),
204 * blocked, cancelled-out-of-reach or unknown, and manual jobs that hold the pipeline.
205 * Passed, pending, skipped and optional manual jobs are only counted.
206 */
207export function jobsNeedingAttention(jobs: readonly MergeWatchJob[]): MergeWatchJob[] {
208 return jobs.filter(
209 j =>
210 j.state === 'failed' ||
211 j.state === 'running' ||
212 j.state === 'blocked' ||
213 j.state === 'unknown' ||
214 j.state === 'unavailable' ||
215 (j.state === 'manual' && j.label.includes('blocking')),
216 )
217}
218
219const COUNT_ORDER: readonly MergeWatchCiState[] = ['passed', 'warning', 'failed', 'running', 'pending', 'manual', 'blocked', 'skipped', 'cancelled', 'unknown', 'unavailable']
220
221/** "26 passed · 1 failed · 3 pending": every job counted, in a fixed order, zeros left out. */
222export function jobCounts(jobs: readonly MergeWatchJob[]): string {
223 const counts = new Map<MergeWatchCiState, number>()
224
225 for (const j of jobs) {
226 counts.set(j.state, (counts.get(j.state) ?? 0) + 1)
227 }
228
229 return COUNT_ORDER.filter(s => (counts.get(s) ?? 0) > 0)
230 .map(s => `${counts.get(s)} ${STATE_WORD[s].toLowerCase()}`)
231 .join(' · ')
232}
233hooks/view-model.ts 177 lines1// What the redesigned pane shows for each request: its group, cleaned title, reason line,
2// pipeline mix and failure lists. Pure functions over the shared display model.
3
4import type { MergeWatchJob, MergeWatchPipeline, MergeWatchRequest } from '../types'
5
6export type Group = 'ready' | 'failing' | 'draft'
7
8export const GROUP_ORDER: readonly Group[] = ['ready', 'failing', 'draft']
9
10export const GROUP_LABEL: Record<Group, string> = {
11 ready: 'READY TO MERGE',
12 failing: 'PIPELINE FAILING',
13 draft: 'DRAFTS',
14}
15
16/** Requests not updated for this long are hidden until "show all". */
17export const STALE_AFTER_MS = 14 * 24 * 60 * 60 * 1000
18
19/** How many real failures the expanded table lists before "+N more failed". */
20export const FAILURES_SHOWN = 5
21
22function flatten(pipelines: readonly MergeWatchPipeline[]): MergeWatchPipeline[] {
23 return pipelines.flatMap(p => [p, ...flatten(p.children)])
24}
25
26/** Jobs of the current revision only: a previous revision's results never count. */
27export function currentJobs(r: MergeWatchRequest): MergeWatchJob[] {
28 return flatten(r.ci.pipelines)
29 .filter(p => !p.isPreviousRevision)
30 .flatMap(p => p.jobs)
31}
32
33export function realFailures(r: MergeWatchRequest): MergeWatchJob[] {
34 return currentJobs(r).filter(j => j.state === 'failed' && !j.isAllowedFailure)
35}
36
37export function allowedFailures(r: MergeWatchRequest): MergeWatchJob[] {
38 return currentJobs(r).filter(j => j.state === 'failed' && j.isAllowedFailure)
39}
40
41const PASSING_CI = new Set(['passed', 'warning', 'no-pipeline', 'no-checks', 'skipped'])
42
43/**
44 * Drafts are drafts whatever their pipeline. Ready means the provider says mergeable and the
45 * pipeline passed ("passed with warnings" counts). Everything else is failing, including a
46 * pipeline that is still running or a request that is waiting for approval.
47 */
48export function groupOf(r: MergeWatchRequest): Group {
49 if (r.isDraft) {
50 return 'draft'
51 }
52
53 return r.readiness === 'Ready to merge' && PASSING_CI.has(r.ci.state) && r.staleSince === null ? 'ready' : 'failing'
54}
55
56/** "feat(design-importer): source-agnostic …" → "source-agnostic …"; also drops "Draft:". */
57export function stripPrefix(title: string): string {
58 const stripped = title
59 .replace(/^\s*(?:draft|wip)\s*:\s*/i, '')
60 .replace(/^\s*\[(?:draft|wip)\]\s*/i, '')
61 .replace(/^[a-z]+(?:\([^)]*\))?!?:\s+/i, '')
62 .trim()
63
64 return stripped === '' ? title : stripped
65}
66
67/** The coloured line under a title: why it is in its group, in a few words. */
68export function reasonLine(r: MergeWatchRequest): string {
69 const group = groupOf(r)
70 const allowed = allowedFailures(r).length
71 const allowedText = allowed === 0 ? '' : `${allowed} allowed failure${allowed === 1 ? '' : 's'}`
72
73 if (r.error !== null && r.staleSince === null) {
74 return `! details unavailable: ${r.error}`
75 }
76
77 if (group === 'ready') {
78 const parts = [r.needsReview ? 'needs review' : '', allowedText].filter(Boolean)
79
80 return `✓ ${parts.length === 0 ? 'ready' : parts.join(' · ')}`
81 }
82
83 const failed = realFailures(r)
84
85 if (failed.length > 5) {
86 const names = failed.slice(0, 3).map(j => j.name)
87
88 return `✕ ${failed.length} jobs failing — ${names.join(', ')}…`
89 }
90
91 if (failed.length > 0) {
92 return `✕ ${failed.map(j => j.name).join(', ')}`
93 }
94
95 if (r.ci.pipelines.some(p => p.isPreviousRevision) && r.ci.pipelines.every(p => p.isPreviousRevision)) {
96 return '↺ pipeline is for a previous revision'
97 }
98
99 const running = currentJobs(r).filter(j => j.state === 'running')
100
101 if (r.ci.state === 'running' || running.length > 0) {
102 return `▶ running${running.length > 0 ? ` — ${running.map(j => j.name).join(', ')}` : ''}`
103 }
104
105 if (r.ci.state === 'pending') {
106 return '○ pipeline pending'
107 }
108
109 if (r.ci.state === 'unknown' || r.ci.state === 'unavailable') {
110 return `? ${r.ci.label.toLowerCase()}`
111 }
112
113 const why = r.blockers.length > 0 ? r.blockers.join(' · ') : r.readiness
114
115 return `· ${why.charAt(0).toLowerCase()}${why.slice(1)}`
116}
117
118export type JobMix = { passed: number; failed: number; allowed: number; manual: number; other: number }
119
120/** Counts for the mini pipeline bar. */
121export function jobMix(r: MergeWatchRequest): JobMix {
122 const mix: JobMix = { passed: 0, failed: 0, allowed: 0, manual: 0, other: 0 }
123
124 for (const j of currentJobs(r)) {
125 if (j.state === 'passed' || j.state === 'warning') {
126 mix.passed += 1
127 } else if (j.state === 'failed') {
128 mix[j.isAllowedFailure ? 'allowed' : 'failed'] += 1
129 } else if (j.state === 'manual') {
130 mix.manual += 1
131 } else {
132 mix.other += 1
133 }
134 }
135
136 return mix
137}
138
139/** Splits `cells` among the mix's segments in proportion; every non-empty segment gets at least one cell. */
140export function splitCells(mix: JobMix, cells: number): JobMix {
141 const keys: (keyof JobMix)[] = ['passed', 'failed', 'allowed', 'manual', 'other']
142 const total = keys.reduce((n, k) => n + mix[k], 0)
143 const out: JobMix = { passed: 0, failed: 0, allowed: 0, manual: 0, other: 0 }
144
145 if (total === 0) {
146 out.other = cells
147
148 return out
149 }
150
151 let used = 0
152
153 for (const k of keys) {
154 out[k] = mix[k] === 0 ? 0 : Math.max(1, Math.floor((mix[k] / total) * cells))
155 used += out[k]
156 }
157
158 // Give or take the rounding difference on the largest segment.
159 const largest = keys.reduce((a, b) => (out[b] > out[a] ? b : a))
160 out[largest] = Math.max(1, out[largest] + cells - used)
161
162 return out
163}
164
165/** "#1470130 · 35 passed · 2 failed · 8 manual" for the head pipeline. */
166export function pipelineSummary(p: MergeWatchPipeline, counts: string): string {
167 const id = /#\d+/.exec(p.title)?.[0] ?? p.title
168
169 return counts === '' ? id : `${id} · ${counts}`
170}
171
172export function isStale(r: MergeWatchRequest, now: number): boolean {
173 const t = Date.parse(r.updatedAt)
174
175 return Number.isFinite(t) && now - t > STALE_AFTER_MS
176}
177types/index.d.ts 147 lines1// Merge Watch's shared display model and its $.state contract.
2// Both adapters (GitLab, GitHub) produce these shapes; the pane only renders them.
3
4export type MergeWatchProvider = 'gitlab' | 'github'
5
6/** A CI state, provider-neutral. Never `passed` unless the provider said so for the current revision. */
7export type MergeWatchCiState =
8 | 'passed'
9 | 'warning'
10 | 'failed'
11 | 'running'
12 | 'pending'
13 | 'manual'
14 | 'blocked'
15 | 'skipped'
16 | 'cancelled'
17 | 'no-pipeline'
18 | 'no-checks'
19 | 'unknown'
20 | 'unavailable'
21
22export type MergeWatchJob = {
23 id: string
24 name: string
25 state: MergeWatchCiState
26 /** The word shown beside the icon, e.g. "Failed (allowed)", "Manual (optional)". */
27 label: string
28 isAllowedFailure: boolean
29 /** A validated http(s) URL, or null when the provider gave no link for this job. */
30 url: string | null
31 stage: string | null
32}
33
34export type MergeWatchPipeline = {
35 /** e.g. "Pipeline #830", "CI workflow", "Commit statuses". */
36 title: string
37 state: MergeWatchCiState
38 label: string
39 url: string | null
40 /** The pipeline belongs to an older commit of the request: its result is not the current one. */
41 isPreviousRevision: boolean
42 /** Some jobs or child pipelines could not be loaded. */
43 isIncomplete: boolean
44 notes: string[]
45 jobs: MergeWatchJob[]
46 children: MergeWatchPipeline[]
47 /** GitLab: the project that owns the pipeline (jobs are retried there). Null elsewhere. */
48 projectId: string | null
49 /** GitHub: the workflow run id (failed jobs are re-run through it). Null elsewhere. */
50 runId: string | null
51}
52
53export type MergeWatchRequest = {
54 /** Unique within the repository: the provider's number as a string. */
55 key: string
56 number: number
57 /** "!142" on GitLab, "#142" on GitHub. */
58 ref: string
59 title: string
60 url: string | null
61 author: string
62 sourceBranch: string
63 targetBranch: string
64 /** Set when the source lives in another project or fork. */
65 sourceProject: string | null
66 isDraft: boolean
67 /** "Awaiting review", "Approved", "Changes requested", ... */
68 review: string
69 /** Request readiness, kept apart from CI. "Ready to merge" only when the provider says so. */
70 readiness: string
71 blockers: string[]
72 ci: {
73 state: MergeWatchCiState
74 label: string
75 pipelines: MergeWatchPipeline[]
76 }
77 updatedAt: string
78 /** The commit a merge is pinned to, so a push after the panel drew is never merged blind. */
79 headSha: string
80 /** The provider says this request can be merged now by you. */
81 canMerge: boolean
82 /** A review or approval is still outstanding. */
83 needsReview: boolean
84 /** This request's data could not be refreshed; what is shown is from `staleSince`. */
85 error: string | null
86 staleSince: number | null
87}
88
89export type MergeWatchRepo = {
90 provider: MergeWatchProvider
91 host: string
92 /** Full project path, nested groups included: "group/sub/project" or "owner/repo". */
93 path: string
94 /** provider:host/path, lower-cased host. Every preference is stored under it. */
95 key: string
96 remoteName: string
97 webUrl: string
98}
99
100export type MergeWatchSnapshot = {
101 repoKey: string
102 /** When the request list was last fetched successfully (ms). Null before the first success. */
103 fetchedAt: number | null
104 requests: MergeWatchRequest[]
105 /** Why the last refresh failed as a whole, if it did. */
106 error: string | null
107 /** Setup instructions when authentication is missing. */
108 authHelp: string | null
109 /** Polling is slowed (rate limit): the next attempt is not before this time (ms). */
110 nextRetryAt: number | null
111}
112
113export type MergeWatchPhase =
114 | 'idle'
115 | 'not-git'
116 | 'no-remote'
117 | 'choose-repo'
118 | 'ready'
119 | 'off'
120
121export type MergeWatchView = {
122 phase: MergeWatchPhase
123 repo: MergeWatchRepo | null
124 candidates: MergeWatchRepo[]
125 isRefreshing: boolean
126 isChoosing: boolean
127 notice: string | null
128 /** A write action waiting for its confirm press: which request and what. */
129 confirm: { key: string; action: 'retry' | 'merge' } | null
130 /** The outcome of the last action per request, shown in its expanded row. */
131 notices: Record<string, string>
132}
133
134declare module 'claude-code' {
135 interface PluginState {
136 'merge-watch': {
137 view: MergeWatchView
138 snapshot: MergeWatchSnapshot | null
139 expanded: Record<string, boolean>
140 /** Show requests not updated in the last 14 days too. */
141 showAll: boolean
142 /** Requests whose expanded job table lists every failure, not the first five. */
143 moreFailed: Record<string, boolean>
144 }
145 }
146}
147