SLOPSHOPPER

merge-watch

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…

newpanecommandtoastprocessnetwork
v0.4.3no licenseupdated 2026-10-08b00y0h/merge-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · merge-watch
│ ┃ Merge Watch ✕ › fix the failing auth test and add an audit log call │ ┃ Merge Watch · │ ┃ This repository has no GitLab or GitHub remo ⏺ Read(src/auth.ts) │ ┃ Watch recognises. For a self-hosted host wit ⎿ Read 6 lines │ ┃ "gitlab" in its name, add it to the gitlab_h ⏺ Update(src/auth.ts) │ ┃ github_hosts option. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /merge-watch │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Merge Watch
Merge Watch · ↻ ⤢ ✕ 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.
README

Merge Watch

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
  • Groups. Ready to merge: GitLab or GitHub says it can merge and the pipeline passed ("passed with warnings" counts). Pipeline failing: everything else that is not a draft, including a pipeline still running or a request waiting for approval. Drafts: drafts, whatever their pipeline. Data that could not be refreshed is never shown as ready.
  • Rows. Each request is two lines: its title without the 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.
  • Click a title (or the ▸) to expand it: the full title, status, branch, pipeline counts, and a table of failed, running and allowed-to-fail jobs, five failures at a time. Job names link to the job. Expanded rows are remembered per repository; ⤢ opens or closes them all.
  • Show all. Requests not updated for 14 days are hidden until you press "show all". That choice is remembered per repository.
  • Actions. Ready and mergeable: Merge. Awaiting review: Review (opens it). Failing: Retry failed, which re-runs only the failed jobs that are not allowed to fail. Merge and Retry ask you to confirm first, and a merge is pinned to the commit the panel showed, so a push in the meantime makes the provider refuse it rather than merge something you did not see.
  • Data that could not be refreshed stays on screen, labelled stale with the time it dates from. A pipeline for an older commit is labelled "Previous revision" and is never shown as the current result.

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.

Install

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.

Sign-in

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:

  • enter it in the plugin's github_token option with /plugin configure merge-watch@merge-watch (stored in your system's secure storage, not in settings files), or
  • start Claude Code with GITHUB_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.

Commands

CommandWhat it does
/merge-watchStart watching this repository and open the panel. Run it again to bring the panel back
/merge-watch refreshRefresh now. A refresh already running is joined, not repeated
/merge-watch hideClose the panel, keep refreshing in the background
/merge-watch offClose the panel and stop refreshing for this repository
/merge-watch onSame as /merge-watch: start (or resume), refresh at once and open the panel
/merge-watch repoShow 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.

Which repository it watches

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.

Troubleshooting

  • "Open a Git project to use Merge Watch." The session is not inside a Git repository.
  • "has no GitLab or GitHub remote Merge Watch recognises" Add your host to gitlab_hosts or github_hosts with /plugin configure merge-watch@merge-watch.
  • A sign-in message in the panel Follow it: glab auth login --hostname <host> for GitLab, or set a GitHub token as above. Then run /merge-watch refresh.
  • "Polling slowed by the provider. Next try at …" The provider rate-limited Merge Watch. It waits until then before asking again, and keeps showing the last data.
  • "Previous revision" The newest pipeline ran for an older commit. Push or re-run CI in GitLab or GitHub, or use Retry failed on the current pipeline.
  • The panel never appears on its own That is by design. Run /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.

Develop

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.

Source 9 files
hooks/register.tsx 9 lines
1import 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}
9
hooks/merge-watch.tsx 1217 lines
1// 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 lines
1// 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}
573
hooks/gitlab.ts 582 lines
1// 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}
582
hooks/repo.ts 165 lines
1// 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}
165
hooks/safe.ts 60 lines
1// 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}
60
hooks/status.ts 233 lines
1// 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}
233
hooks/view-model.ts 177 lines
1// 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}
177
types/index.d.ts 147 lines
1// 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