SLOPSHOPPER

merge-status

Shows the merge requests of the current branch under the prompt: target branch, pipeline and conflicts (GitLab via glab, GitHub via gh)

newspinnerguardcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · merge-status
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mrs ⎿ merge-status: MRs hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

merge-status

A Claude Code mod that shows the open merge requests (GitLab) or pull requests (GitHub) of your current branch in a row under the prompt:

Two open GitHub pull requests with running checks, one of them a draft with a merge conflict

Only open MRs and PRs are shown, drafts included. Merged and closed ones are left out, and a branch without an open one shows none.

The forge comes from the origin remote: a host of github.com or one starting with github. (GitHub Enterprise, for example github.acme.com) is GitHub and uses gh; any other host is GitLab and uses glab. In the rest of this README, "MR" covers GitHub pull requests too.

Screenshots

Taken in Claude Code on this repository's demo pull requests, and on a GitLab merge request.

Checks passed on both pull requests:

Both pull requests with passed checks

Checks failed, with a merge conflict on the draft:

Both pull requests with failed checks and a conflict sign on the draft

Claude investigates the failure on its own, without changing code:

Claude reads the failed check logs and reports the root cause

GitLab merge requests through glab:

A GitLab merge request with a passed pipeline

No open pull request on the branch:

The row says none

A setup hint when the CLI is not logged in:

The row says gh is not logged in and how to fix it

Features

  • GitLab and GitHub: GitLab merge requests through glab, GitHub pull requests through gh, picked from the origin remote.
  • Pipeline and conflicts at a glance: one sign per MR for the pipeline (passed, running, failed, none) and a warning for merge conflicts. On GitHub, all checks of the head commit count as its pipeline.
  • Follows your Claude Code theme: all colors come from the theme, and switching it with /theme recolors the row right away.
  • Stays current on its own: refreshes after a checkout, switch, push, pull or new MR, whether Claude runs it or you do, and drops MRs merged or closed elsewhere.
  • No stale results after a push: waits for the new pipeline instead of showing the previous one as passed.
  • Investigates failed pipelines: when a pipeline you saw running fails, Claude looks into the failed jobs and reports the cause, without changing code.
  • Clickable MRs: each MR number links to its page, and /mr opens one in your browser.
  • Light on resources: runs glab or gh and local git only, and costs no tokens apart from failure investigations. Pipelines are polled only while running or failed; otherwise one call every 2 minutes re-lists the open MRs.
  • Clear setup hints: if the CLI for your forge is missing or not logged in, the row says what to run.
  • Cross-platform: macOS, Linux and Windows.

Requirements

  • Claude Code with function-hook plugins (mods), tested on 2.1.291. The mod API is in early access and may change between releases.
  • For GitLab: glab, the GitLab CLI, on your PATH (tested with 1.80), logged in to your GitLab host. See its installation guide, for example: ``sh brew install glab # macOS, Linux (Homebrew) winget install GLab.GLab # Windows glab auth login --hostname git.example.com ``
  • For GitHub: gh, the GitHub CLI, on your PATH (tested with 2.73), logged in to your GitHub host. See its installation guide, for example: ``sh brew install gh # macOS, Linux (Homebrew) winget install GitHub.cli # Windows gh auth login `` You only need the CLI of the forge you use.
  • An origin remote on that host. Repositories without an origin remote are ignored.
  • Optional: a Nerd Font in your terminal for the GitLab and GitHub logos. Without one, set icons to text (see Settings).
  • macOS, Linux or Windows. /mr opens links with open on macOS, xdg-open on Linux and the system URL handler on Windows. Developed and verified on macOS; Linux and Windows are covered by tests only.

If the CLI is missing or not logged in, the row says so and tells you how to fix it.

Install

  1. Install the CLI of your forge and log in: glab auth login for GitLab, gh auth login for GitHub (see Requirements).
  2. In Claude Code, add the marketplace and install the mod: `` /plugin marketplace add JanSuthacheeva/claude-code-mods /plugin install merge-status@claude-code-mods ``
  3. No Nerd Font in your terminal? Switch the logo to a text label: `` /plugin configure merge-status@claude-code-mods ` and set icons to text`.
  4. Restart Claude Code. In a repository with an open MR or PR on its current branch, the row appears under the prompt.

For development, link a clone of this repository instead:

ln -s "$PWD/claude-code-mods/merge-status" ~/.claude/skills/merge-status

The row

SignMeaning
!3609 / #128MR or PR number, linked to its page
open / draftOpen MR, ready or draft
→ developTarget branch
✓ ⟳ ✗ ○Pipeline passed, running, failed, none
⚠Merge conflict

MRs are ordered by target branch: feature and bugfix branches first, then develop, sprint-* and main.

Settings

SettingValuesDefault
iconsnerd-font: GitLab or GitHub logonerd-font
text: MR or PR label

Change it with /plugin configure merge-status@claude-code-mods in Claude Code, then restart.

Commands

  • /mrs hides or shows the row.
  • /mr opens the first MR in the browser, /mr 2 the second, /mr 3609 a specific one.

What it fetches, and when

Everything runs through glab or gh and local git; nothing reaches the model or costs tokens, except failure investigations.

  • Full refresh on session start and after a checkout, switch, push, pull, glab mr create or gh pr create, whether Claude runs it or you do (a local git check runs every 15 seconds).
  • Open MRs are re-listed every 2 minutes, so MRs merged or closed elsewhere disappear.
  • Pipelines are polled every 30 seconds while running and every 2 minutes after failing, and never once passed. Right after a push, the previous pipeline counts as running until the forge starts the new one.
  • Failed pipelines you saw running start one Claude turn that investigates the failure and reports the cause, without changing code. This turn costs tokens.

Development

claude plugin validate .
claude plugin test .
Source 11 files
hooks/register.tsx 330 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register } from 'claude-code'
3
4import type { BranchStatus, MergeRequest } from '../types'
5import { detectPlatform, isWindows, openUrlArgv } from './browser'
6import type { Platform } from './browser'
7import { describeProcessError, detectForge, forgeNamed } from './forge'
8import type { Forge } from './forge'
9import { isTriggeringCommand, remoteBranchName, toFingerprint } from './git'
10import type { Fingerprint, Location } from './git'
11import { openByPriority, pickMergeRequest } from './merge-requests'
12import { needsPolling, newlyFailed, resolvePipeline } from './pipelines'
13import {
14  hasMovedUpstream,
15  initialTracking,
16  nextWatchAction,
17  pushGracePeriodMs,
18  sameNumbers,
19  watchIntervalMs,
20} from './schedule'
21import { iconStyleFrom, statusSegments } from './status-line'
22
23type Listing = { mergeRequests: MergeRequest[] } | { error: string }
24
25const commandTimeoutMs = 20_000
26const visibilityStoreKey = 'isVisible'
27
28const emptyStatus: BranchStatus = { repo: null, branch: null, forge: null, mergeRequests: null, error: null }
29
30const branchStatus = atom({ plugin: 'merge-status', key: 'branchStatus' } as const, emptyStatus)
31const isVisible = atom({ plugin: 'merge-status', key: 'isVisible' } as const, true)
32
33const tracking = initialTracking()
34
35let platform: Platform | null = null
36
37// Host commands
38
39async function run($: EngineInterface, argv: readonly string[]): Promise<ProcessRunResult> {
40  return $.process.run(argv, { timeoutMs: commandTimeoutMs })
41}
42
43async function output($: EngineInterface, argv: readonly string[]): Promise<string | null> {
44  const { exitCode, stdout } = await run($, argv)
45  const text = stdout.trim()
46  return exitCode === 0 && text !== '' ? text : null
47}
48
49// Browser
50
51async function hostPlatform($: EngineInterface): Promise<Platform> {
52  if (platform !== null) return platform
53
54  const osVariable = await $.env.get('OS')
55  const kernelName = isWindows(osVariable) ? null : await output($, ['uname', '-s'])
56  platform = detectPlatform(osVariable, kernelName)
57  return platform
58}
59
60async function openInBrowser($: EngineInterface, url: string): Promise<string | null> {
61  try {
62    const { exitCode, stderr } = await run($, openUrlArgv(await hostPlatform($), url))
63    return exitCode === 0 ? null : stderr.trim() || `exit code ${String(exitCode)}`
64  } catch (error) {
65    return String(error)
66  }
67}
68
69// Git
70
71async function readLocation($: EngineInterface): Promise<Location | null> {
72  const repo = await output($, ['git', 'rev-parse', '--show-toplevel'])
73  const localBranch = await output($, ['git', 'branch', '--show-current'])
74  if (repo === null || localBranch === null) return null
75
76  const forge = detectForge(await output($, ['git', 'remote', 'get-url', 'origin']))
77  if (forge === null) return null
78
79  const upstreamRef = await output($, ['git', 'rev-parse', '--abbrev-ref', '@{upstream}'])
80  return { repo, branch: remoteBranchName(upstreamRef, localBranch), forge: forge.name }
81}
82
83async function readFingerprint($: EngineInterface): Promise<Fingerprint> {
84  const head = await output($, ['git', 'rev-parse', '--show-toplevel', '--abbrev-ref', 'HEAD'])
85  const upstreamSha = await output($, ['git', 'rev-parse', '@{upstream}'])
86  return toFingerprint(head, upstreamSha)
87}
88
89// Forge
90
91async function listOpen($: EngineInterface, forge: Forge, sourceBranch: string): Promise<Listing> {
92  try {
93    const { exitCode, stdout, stderr } = await run($, forge.listArgv(sourceBranch))
94    if (exitCode !== 0) return { error: forge.describeFailure(`${stderr}\n${stdout}`) }
95    return { mergeRequests: openByPriority(forge.parseMergeRequests(stdout)) }
96  } catch (error) {
97    return { error: describeProcessError(forge, error) }
98  }
99}
100
101async function withPipeline(
102  $: EngineInterface,
103  forge: Forge,
104  mr: MergeRequest,
105  previous: MergeRequest | undefined,
106): Promise<MergeRequest> {
107  const json = await output($, forge.detailsArgv(mr))
108  if (json === null)
109    return { ...mr, pipeline: previous?.pipeline ?? null, hasConflicts: previous?.hasConflicts ?? false }
110
111  const details = forge.parseDetails(json)
112  const context = {
113    pushedSha: tracking.fingerprint?.upstreamSha ?? null,
114    isWithinPushGrace: (await $.clock.now()) < tracking.pushGraceUntil,
115    hadPipeline: (previous?.pipeline ?? null) !== null,
116  }
117  return { ...mr, pipeline: resolvePipeline(details, context), hasConflicts: details.hasConflicts }
118}
119
120async function reportFailures(
121  $: EngineInterface,
122  forge: Forge,
123  sourceBranch: string,
124  before: readonly MergeRequest[],
125  after: readonly MergeRequest[],
126): Promise<void> {
127  for (const mr of newlyFailed(before, after)) {
128    const storeKey = `investigated:${forge.name}:${mr.project}:${mr.pipeline.id}`
129    if ((await $.store.get(storeKey)) === true) continue
130
131    await $.store.set(storeKey, true)
132    void $.prompt.submit({ text: forge.investigationPrompt(mr, sourceBranch) })
133  }
134}
135
136// Tracking
137
138async function setBranchStatus($: EngineInterface, next: BranchStatus): Promise<void> {
139  const current = await read($, branchStatus)
140  if (JSON.stringify(current) !== JSON.stringify(next)) await update($, branchStatus, () => next)
141}
142
143function isStale(generation: number): boolean {
144  return generation !== tracking.generation || tracking.isRefreshing
145}
146
147async function load($: EngineInterface): Promise<void> {
148  const fingerprint = await readFingerprint($)
149  const now = await $.clock.now()
150  if (hasMovedUpstream(tracking.fingerprint, fingerprint)) tracking.pushGraceUntil = now + pushGracePeriodMs
151  tracking.fingerprint = fingerprint
152  tracking.listedAt = now
153  tracking.pipelinesPolledAt = now
154
155  const location = await readLocation($)
156  if (location === null) {
157    await setBranchStatus($, emptyStatus)
158    return
159  }
160
161  const forge = forgeNamed(location.forge)
162  const current = await read($, branchStatus)
163  const isSameBranch =
164    current.repo === location.repo && current.branch === location.branch && current.forge === location.forge
165  if (!isSameBranch) await setBranchStatus($, { ...location, mergeRequests: null, error: null })
166
167  const listing = await listOpen($, forge, location.branch)
168  if ('error' in listing) {
169    await setBranchStatus($, { ...location, mergeRequests: null, error: listing.error })
170    return
171  }
172
173  const previous = isSameBranch ? (current.mergeRequests ?? []) : []
174  const previousByNumber = new Map(previous.map(mr => [mr.number, mr]))
175  const mergeRequests = await Promise.all(
176    listing.mergeRequests.map(mr => withPipeline($, forge, mr, previousByNumber.get(mr.number))),
177  )
178  await setBranchStatus($, { ...location, mergeRequests, error: null })
179  await reportFailures($, forge, location.branch, previous, mergeRequests)
180}
181
182async function refresh($: EngineInterface): Promise<void> {
183  if (tracking.isRefreshing) {
184    tracking.isRefreshQueued = true
185    return
186  }
187
188  tracking.isRefreshing = true
189  tracking.generation += 1
190  try {
191    await load($)
192  } catch (error) {
193    await setBranchStatus($, { ...(await read($, branchStatus)), error: String(error) })
194  } finally {
195    tracking.isRefreshing = false
196  }
197
198  if (tracking.isRefreshQueued) {
199    tracking.isRefreshQueued = false
200    await refresh($)
201  }
202}
203
204async function recheck($: EngineInterface, status: BranchStatus): Promise<void> {
205  const generation = tracking.generation
206  tracking.listedAt = await $.clock.now()
207  if (status.branch === null || status.forge === null) return
208
209  const listing = await listOpen($, forgeNamed(status.forge), status.branch)
210  if ('error' in listing || isStale(generation)) return
211  if (!sameNumbers(listing.mergeRequests, status.mergeRequests ?? [])) await refresh($)
212}
213
214async function pollPipelines($: EngineInterface, status: BranchStatus): Promise<void> {
215  const generation = tracking.generation
216  tracking.pipelinesPolledAt = await $.clock.now()
217  if (status.branch === null || status.forge === null) return
218
219  const forge = forgeNamed(status.forge)
220  const previous = status.mergeRequests ?? []
221  const polled = await Promise.all(previous.map(async mr => (needsPolling(mr) ? withPipeline($, forge, mr, mr) : mr)))
222  if (isStale(generation)) return
223
224  await setBranchStatus($, { ...status, mergeRequests: polled })
225  await reportFailures($, forge, status.branch, previous, polled)
226}
227
228async function watch($: EngineInterface): Promise<void> {
229  if (tracking.isRefreshing || !(await read($, isVisible))) return
230
231  const fingerprint = await readFingerprint($)
232  const status = await read($, branchStatus)
233  const action = nextWatchAction(tracking, fingerprint, status.mergeRequests ?? [], await $.clock.now())
234  if (action === 'refresh') await refresh($)
235  if (action === 'recheck') await recheck($, status)
236  if (action === 'poll') await pollPipelines($, status)
237}
238
239async function setVisible($: EngineInterface, visible: boolean): Promise<void> {
240  await update($, isVisible, () => visible)
241  await $.store.set(visibilityStoreKey, visible)
242  if (visible) await refresh($)
243}
244
245// Hooks
246
247export const register: Register = (on, options) => {
248  const iconStyle = iconStyleFrom(options['icons'])
249
250  on('session.start', async ($, e, next) => {
251    const result = await next(e)
252    await $.command.register({
253      name: 'mrs',
254      description:
255        'Toggle the merge requests (GitLab) or pull requests (GitHub) of the current branch under the prompt',
256    })
257    await $.command.register({
258      name: 'mr',
259      description:
260        'Open an MR or PR of the current branch in the browser: /mr (first), /mr 2 (second), /mr 3609 (by number)',
261    })
262
263    const visible = (await $.store.get(visibilityStoreKey)) !== false
264    await update($, isVisible, () => visible)
265    if (visible) void refresh($)
266    $.clock.every(watchIntervalMs, () => void watch($))
267    return result
268  })
269
270  on('command.run', { command: 'mrs' }, async $ => {
271    const visible = !(await read($, isVisible))
272    await setVisible($, visible)
273    return { text: visible ? 'MRs shown.' : 'MRs hidden.' }
274  })
275
276  on('command.run', { command: 'mr' }, async ($, e) => {
277    const status = await read($, branchStatus)
278    const forge = status.forge === null ? null : forgeNamed(status.forge)
279    const noun = forge?.noun ?? 'MR'
280    const mergeRequests = status.mergeRequests ?? []
281    if (forge === null || mergeRequests.length === 0) return { text: `No open ${noun} for this branch.` }
282
283    const labelOf = (mr: MergeRequest): string => `${forge.sigil}${String(mr.number)}`
284    const mr = pickMergeRequest(mergeRequests, e.args)
285    if (mr === undefined) {
286      const available = mergeRequests.map(labelOf).join(', ')
287      return { text: `No ${noun} "${e.args.trim()}" here. Open: ${available}` }
288    }
289
290    const failure = await openInBrowser($, mr.url)
291    const label = labelOf(mr)
292    return { text: failure === null ? `Opened ${label}.` : `Could not open ${label}: ${failure}` }
293  })
294
295  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
296    const result = await next(e)
297    if (isTriggeringCommand(e.command) && (await read($, isVisible))) void refresh($)
298    return result
299  })
300
301  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
302    const engineHint = await next(e)
303    const segments = (await read($, isVisible)) ? statusSegments(await read($, branchStatus), iconStyle) : null
304    if (segments === null) return engineHint
305
306    const { Box, Link, Text } = $.ui.resolve(e)
307    return (
308      <Box flexDirection="column">
309        {engineHint}
310        <Text wrap="truncate-end">
311          {segments.map((segment, index) => {
312            const text = (
313              <Text key={`text-${String(index)}`} color={segment.color}>
314                {segment.text}
315              </Text>
316            )
317            return segment.href === undefined ? (
318              text
319            ) : (
320              <Link key={`link-${String(index)}`} href={segment.href}>
321                {text}
322              </Link>
323            )
324          })}
325        </Text>
326      </Box>
327    )
328  })
329}
330
hooks/browser.ts 22 lines
1export type Platform = 'macos' | 'linux' | 'windows'
2
3export function isWindows(osVariable: string | undefined): boolean {
4  return osVariable === 'Windows_NT'
5}
6
7export function detectPlatform(osVariable: string | undefined, kernelName: string | null): Platform {
8  if (isWindows(osVariable)) return 'windows'
9  return kernelName === 'Darwin' ? 'macos' : 'linux'
10}
11
12export function openUrlArgv(platform: Platform, url: string): string[] {
13  switch (platform) {
14    case 'macos':
15      return ['open', url]
16    case 'linux':
17      return ['xdg-open', url]
18    case 'windows':
19      return ['rundll32', 'url.dll,FileProtocolHandler', url]
20  }
21}
22
hooks/forge.ts 53 lines
1import type { ForgeName, MergeRequest, ResolvedPipeline } from '../types'
2import { remoteHost } from './git'
3import { github } from './github'
4import { gitlab } from './gitlab'
5import type { FailedMergeRequest } from './pipelines'
6
7export interface HeadPipeline extends ResolvedPipeline {
8  sha: string
9}
10
11export interface MergeRequestDetails {
12  sha: string
13  hasConflicts: boolean
14  headPipeline: HeadPipeline | null
15}
16
17export interface Forge {
18  name: ForgeName
19  cli: 'glab' | 'gh'
20  noun: 'MR' | 'PR'
21  sigil: '!' | '#'
22  logo: string
23  notInstalledMessage: string
24  notLoggedInMessage: string
25  listArgv(sourceBranch: string): string[]
26  parseMergeRequests(json: string): MergeRequest[]
27  detailsArgv(mr: MergeRequest): string[]
28  parseDetails(json: string): MergeRequestDetails
29  describeFailure(output: string): string
30  investigationPrompt(mr: FailedMergeRequest, sourceBranch: string): string
31}
32
33const forges: Record<ForgeName, Forge> = { gitlab, github }
34
35export function forgeNamed(name: ForgeName): Forge {
36  return forges[name]
37}
38
39export function detectForge(remoteUrl: string | null): Forge | null {
40  if (remoteUrl === null) return null
41
42  const host = remoteHost(remoteUrl)
43  if (host === null) return null
44  return host === 'github.com' || host.startsWith('github.') ? github : gitlab
45}
46
47export function describeProcessError(forge: Forge, error: unknown): string {
48  const message = String(error)
49  if (message.includes('ENOENT')) return forge.notInstalledMessage
50  if (message.includes('still running')) return `${forge.cli} did not answer in time`
51  return message
52}
53
hooks/git.ts 36 lines
1import type { ForgeName } from '../types'
2
3export interface Location {
4  repo: string
5  branch: string
6  forge: ForgeName
7}
8
9export interface Fingerprint {
10  key: string
11  upstreamSha: string | null
12}
13
14const urlRemote = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^:/]+)/i
15const scpRemote = /^(?:[^@/]+@)?([^:/\\]{2,}):(?!\/\/)/
16const triggeringCommand =
17  /\bgit\b(?:\s+-C\s+\S+)?\s+(?:checkout|switch|push|pull)\b|\b(?:glab\s+mr|gh\s+pr)\s+(?:create|new)\b/
18
19export function isTriggeringCommand(command: string): boolean {
20  return triggeringCommand.test(command)
21}
22
23export function remoteHost(remoteUrl: string): string | null {
24  const host = urlRemote.exec(remoteUrl)?.[1] ?? scpRemote.exec(remoteUrl)?.[1]
25  return host?.toLowerCase() ?? null
26}
27
28export function remoteBranchName(upstreamRef: string | null, localBranch: string): string {
29  const slash = upstreamRef?.indexOf('/') ?? -1
30  return upstreamRef !== null && slash > 0 ? upstreamRef.slice(slash + 1) : localBranch
31}
32
33export function toFingerprint(head: string | null, upstreamSha: string | null): Fingerprint {
34  return { key: `${head ?? ''}\n${upstreamSha ?? ''}`, upstreamSha }
35}
36
hooks/merge-requests.ts 24 lines
1import type { MergeRequest } from '../types'
2
3function targetPriority(targetBranch: string): number {
4  if (/^(?:develop|development|dev)$/.test(targetBranch)) return 1
5  if (/^sprint/i.test(targetBranch)) return 2
6  if (/^(?:main|master)$/.test(targetBranch)) return 3
7  return 0
8}
9
10export function openByPriority(mergeRequests: readonly MergeRequest[]): MergeRequest[] {
11  return mergeRequests
12    .filter(mr => mr.state === 'open')
13    .sort((a, b) => targetPriority(a.targetBranch) - targetPriority(b.targetBranch) || b.number - a.number)
14}
15
16export function pickMergeRequest(mergeRequests: readonly MergeRequest[], query: string): MergeRequest | undefined {
17  const wanted = query.trim().replace(/^[!#]/, '')
18  if (wanted === '') return mergeRequests[0]
19
20  const number = Number(wanted)
21  if (!Number.isInteger(number) || number < 1) return undefined
22  return mergeRequests.find(mr => mr.number === number) ?? mergeRequests[number - 1]
23}
24
hooks/pipelines.ts 55 lines
1import type { MergeRequest, Pipeline, ResolvedPipeline } from '../types'
2import type { MergeRequestDetails } from './forge'
3
4export interface PipelineContext {
5  pushedSha: string | null
6  isWithinPushGrace: boolean
7  hadPipeline: boolean
8}
9
10export type FailedMergeRequest = MergeRequest & { pipeline: ResolvedPipeline }
11
12const runningStatuses = new Set<Pipeline['status']>(['awaiting', 'running'])
13const failedStatuses = new Set<Pipeline['status']>(['failed', 'canceled'])
14
15const runningPollMs = 30_000
16const failedPollMs = 2 * 60_000
17
18export function isRunning(pipeline: Pipeline | null): boolean {
19  return pipeline !== null && runningStatuses.has(pipeline.status)
20}
21
22export function hasFailed(pipeline: Pipeline | null): boolean {
23  return pipeline !== null && failedStatuses.has(pipeline.status)
24}
25
26export function resolvePipeline(details: MergeRequestDetails, context: PipelineContext): Pipeline | null {
27  const pipeline = details.headPipeline
28  const isPushPending = context.isWithinPushGrace && context.pushedSha !== null && details.sha !== context.pushedSha
29  if (pipeline !== null && pipeline.sha === details.sha && !isPushPending) {
30    return { status: pipeline.status, id: pipeline.id, url: pipeline.url }
31  }
32
33  const isNewPipelineExpected = context.isWithinPushGrace && (pipeline !== null || context.hadPipeline)
34  return isNewPipelineExpected ? { status: 'awaiting' } : null
35}
36
37export function needsPolling(mr: MergeRequest): boolean {
38  return mr.state === 'open' && (isRunning(mr.pipeline) || hasFailed(mr.pipeline))
39}
40
41export function pollIntervalMs(mergeRequests: readonly MergeRequest[]): number | null {
42  const polled = mergeRequests.filter(needsPolling)
43  if (polled.some(mr => isRunning(mr.pipeline))) return runningPollMs
44  return polled.length > 0 ? failedPollMs : null
45}
46
47function isFailed(mr: MergeRequest): mr is FailedMergeRequest {
48  return mr.pipeline?.status === 'failed'
49}
50
51export function newlyFailed(before: readonly MergeRequest[], after: readonly MergeRequest[]): FailedMergeRequest[] {
52  const previous = new Map(before.map(mr => [mr.number, mr.pipeline]))
53  return after.filter(isFailed).filter(mr => isRunning(previous.get(mr.number) ?? null))
54}
55
hooks/schedule.ts 55 lines
1import type { MergeRequest } from '../types'
2import type { Fingerprint } from './git'
3import { pollIntervalMs } from './pipelines'
4
5export const watchIntervalMs = 15_000
6export const pushGracePeriodMs = 5 * 60_000
7
8const recheckIntervalMs = 2 * 60_000
9
10export interface Tracking {
11  fingerprint: Fingerprint | null
12  pushGraceUntil: number
13  listedAt: number
14  pipelinesPolledAt: number
15  generation: number
16  isRefreshing: boolean
17  isRefreshQueued: boolean
18}
19
20export type WatchAction = 'refresh' | 'recheck' | 'poll' | 'idle'
21
22export function initialTracking(): Tracking {
23  return {
24    fingerprint: null,
25    pushGraceUntil: 0,
26    listedAt: 0,
27    pipelinesPolledAt: 0,
28    generation: 0,
29    isRefreshing: false,
30    isRefreshQueued: false,
31  }
32}
33
34export function nextWatchAction(
35  tracking: Tracking,
36  fingerprint: Fingerprint,
37  mergeRequests: readonly MergeRequest[],
38  now: number,
39): WatchAction {
40  if (fingerprint.key !== tracking.fingerprint?.key) return 'refresh'
41  if (mergeRequests.length > 0 && now - tracking.listedAt >= recheckIntervalMs) return 'recheck'
42
43  const interval = pollIntervalMs(mergeRequests)
44  return interval !== null && now - tracking.pipelinesPolledAt >= interval ? 'poll' : 'idle'
45}
46
47export function hasMovedUpstream(previous: Fingerprint | null, next: Fingerprint): boolean {
48  const previousSha = previous?.upstreamSha ?? null
49  return previousSha !== null && next.upstreamSha !== previousSha
50}
51
52export function sameNumbers(a: readonly MergeRequest[], b: readonly MergeRequest[]): boolean {
53  return a.map(mr => mr.number).join(',') === b.map(mr => mr.number).join(',')
54}
55
hooks/status-line.ts 53 lines
1import type { BranchStatus, IconStyle, MergeRequest, Pipeline } from '../types'
2import { forgeNamed } from './forge'
3import type { Forge } from './forge'
4import { hasFailed, isRunning } from './pipelines'
5
6export type ThemeColor = 'text' | 'subtle' | 'inactive' | 'claude' | 'success' | 'warning' | 'error'
7
8export interface Segment {
9  text: string
10  color: ThemeColor
11  href?: string
12}
13
14const space: Segment = { text: ' ', color: 'text' }
15const separator: Segment = { text: ' · ', color: 'subtle' }
16const conflictSign: Segment = { text: '⚠', color: 'error' }
17
18function pipelineSign(pipeline: Pipeline | null): Segment {
19  if (pipeline?.status === 'passed') return { text: '✓', color: 'success' }
20  if (isRunning(pipeline)) return { text: '⟳', color: 'warning' }
21  if (hasFailed(pipeline)) return { text: '✗', color: 'error' }
22  return { text: '○', color: 'inactive' }
23}
24
25function mergeRequestSegments(forge: Forge, mr: MergeRequest): Segment[] {
26  const signs = mr.hasConflicts ? [pipelineSign(mr.pipeline), conflictSign] : [pipelineSign(mr.pipeline)]
27  return [
28    { text: `${forge.sigil}${String(mr.number)}`, color: 'claude', href: mr.url },
29    space,
30    mr.isDraft ? { text: 'draft', color: 'warning' } : { text: 'open', color: 'success' },
31    { text: ' → ', color: 'subtle' },
32    { text: mr.targetBranch, color: 'text' },
33    ...signs.flatMap(sign => [space, sign]),
34  ]
35}
36
37export function iconStyleFrom(option: unknown): IconStyle {
38  return option === 'text' ? 'text' : 'nerd-font'
39}
40
41export function statusSegments(status: BranchStatus, iconStyle: IconStyle): Segment[] | null {
42  if (status.branch === null || status.forge === null) return null
43
44  const forge = forgeNamed(status.forge)
45  const label: Segment = { text: iconStyle === 'text' ? forge.noun : forge.logo, color: 'text' }
46  if (status.error !== null) return [label, { text: ` ${status.error.split('\n')[0] ?? ''}`, color: 'error' }]
47  if (status.mergeRequests === null) return [label, { text: ' loading...', color: 'inactive' }]
48  if (status.mergeRequests.length === 0) return [label, { text: ' none', color: 'inactive' }]
49
50  const entries = status.mergeRequests.map(mr => mergeRequestSegments(forge, mr))
51  return [label, space, ...entries.flatMap((entry, index) => (index === 0 ? entry : [separator, ...entry]))]
52}
53
hooks/github.ts 180 lines
1import type { MergeRequest, MergeRequestState, PipelineStatus } from '../types'
2import type { Forge, MergeRequestDetails } from './forge'
3import type { FailedMergeRequest } from './pipelines'
4
5type ApiPullRequestState = 'OPEN' | 'CLOSED' | 'MERGED'
6
7interface ApiPullRequest {
8  number: number
9  state: ApiPullRequestState
10  isDraft: boolean
11  baseRefName: string
12  url: string
13}
14
15interface ApiCheckRun {
16  __typename: 'CheckRun'
17  status: 'QUEUED' | 'IN_PROGRESS' | 'COMPLETED' | 'WAITING' | 'PENDING' | 'REQUESTED'
18  conclusion:
19    | 'SUCCESS'
20    | 'FAILURE'
21    | 'CANCELLED'
22    | 'SKIPPED'
23    | 'NEUTRAL'
24    | 'TIMED_OUT'
25    | 'ACTION_REQUIRED'
26    | 'STARTUP_FAILURE'
27    | 'STALE'
28    | ''
29    | null
30}
31
32interface ApiStatusContext {
33  __typename: 'StatusContext'
34  state: 'EXPECTED' | 'PENDING' | 'SUCCESS' | 'FAILURE' | 'ERROR'
35}
36
37export type ApiCheck = ApiCheckRun | ApiStatusContext
38
39interface ApiPullRequestDetails {
40  headRefOid: string
41  mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
42  statusCheckRollup: ApiCheck[]
43  url: string
44}
45
46const listLimit = 20
47
48export const notInstalledMessage = 'gh is not installed - see https://cli.github.com'
49export const notLoggedInMessage = 'gh is not logged in to this GitHub host - run: gh auth login'
50
51const notLoggedInOutput = /gh auth login|HTTP 401|Bad credentials/i
52const pullRequestUrl = /^https?:\/\/([^/]+\/[^/]+\/[^/]+)\/pull\/\d+/
53
54const states: Record<ApiPullRequestState, MergeRequestState> = {
55  OPEN: 'open',
56  MERGED: 'merged',
57  CLOSED: 'closed',
58}
59
60const failedConclusions = new Set<ApiCheckRun['conclusion']>([
61  'FAILURE',
62  'TIMED_OUT',
63  'STARTUP_FAILURE',
64  'ACTION_REQUIRED',
65])
66
67function isPending(check: ApiCheck): boolean {
68  return check.__typename === 'CheckRun'
69    ? check.status !== 'COMPLETED'
70    : check.state === 'PENDING' || check.state === 'EXPECTED'
71}
72
73function isFailed(check: ApiCheck): boolean {
74  return check.__typename === 'CheckRun'
75    ? failedConclusions.has(check.conclusion)
76    : check.state === 'FAILURE' || check.state === 'ERROR'
77}
78
79function isCanceled(check: ApiCheck): boolean {
80  return check.__typename === 'CheckRun' && check.conclusion === 'CANCELLED'
81}
82
83export function describeFailure(output: string): string {
84  if (notLoggedInOutput.test(output)) return notLoggedInMessage
85
86  const lines = output.split('\n').map(line => line.trim())
87  return lines.find(line => line !== '') ?? 'gh failed'
88}
89
90export function listArgv(sourceBranch: string): string[] {
91  return [
92    'gh',
93    'pr',
94    'list',
95    '--head',
96    sourceBranch,
97    '--state',
98    'all',
99    '--json',
100    'number,state,isDraft,baseRefName,url',
101    '--limit',
102    String(listLimit),
103  ]
104}
105
106export function detailsArgv(mr: MergeRequest): string[] {
107  return [
108    'gh',
109    'pr',
110    'view',
111    String(mr.number),
112    '--repo',
113    mr.project,
114    '--json',
115    'headRefOid,mergeable,statusCheckRollup,url',
116  ]
117}
118
119export function parsePullRequests(json: string): MergeRequest[] {
120  const listed = JSON.parse(json) as ApiPullRequest[]
121  return listed.map(pr => ({
122    number: pr.number,
123    project: pullRequestUrl.exec(pr.url)?.[1] ?? '',
124    state: states[pr.state],
125    isDraft: pr.isDraft,
126    targetBranch: pr.baseRefName,
127    url: pr.url,
128    pipeline: null,
129    hasConflicts: false,
130  }))
131}
132
133export function rollupStatus(checks: readonly ApiCheck[]): PipelineStatus | null {
134  if (checks.length === 0) return null
135  if (checks.some(isPending)) return 'running'
136  if (checks.some(isFailed)) return 'failed'
137  if (checks.some(isCanceled)) return 'canceled'
138  return 'passed'
139}
140
141export function parseDetails(json: string): MergeRequestDetails {
142  const details = JSON.parse(json) as ApiPullRequestDetails
143  const status = rollupStatus(details.statusCheckRollup)
144  const sha = details.headRefOid
145  return {
146    sha,
147    hasConflicts: details.mergeable === 'CONFLICTING',
148    headPipeline: status && { status, id: sha, sha, url: `${details.url}/checks` },
149  }
150}
151
152export function investigationPrompt(mr: FailedMergeRequest, sourceBranch: string): string {
153  const { project, pipeline } = mr
154  const failedChecks = `gh pr checks ${String(mr.number)} --repo ${project}`
155  const runLog = `gh run view <run-id> --repo ${project} --log-failed`
156
157  return [
158    `The GitHub checks of PR #${String(mr.number)} (${sourceBranch} → ${mr.targetBranch}) on commit ${pipeline.id.slice(0, 7)} just failed: ${pipeline.url}`,
159    `Investigate why: list the failed checks with \`${failedChecks}\` and read each failed run's log with \`${runLog}\`.`,
160    'Report the root cause and the files or tests involved.',
161    'Investigate only: do not change code, commit, push or re-run the checks.',
162  ].join('\n')
163}
164
165export const github: Forge = {
166  name: 'github',
167  cli: 'gh',
168  noun: 'PR',
169  sigil: '#',
170  logo: '\u{f02a4} ',
171  notInstalledMessage,
172  notLoggedInMessage,
173  listArgv,
174  parseMergeRequests: parsePullRequests,
175  detailsArgv,
176  parseDetails,
177  describeFailure,
178  investigationPrompt,
179}
180
hooks/gitlab.ts 158 lines
1import type { MergeRequest, MergeRequestState, PipelineStatus } from '../types'
2import type { Forge, MergeRequestDetails } from './forge'
3import type { FailedMergeRequest } from './pipelines'
4
5type ApiMergeRequestState = 'opened' | 'merged' | 'closed' | 'locked'
6
7type ApiPipelineStatus =
8  | 'created'
9  | 'waiting_for_resource'
10  | 'preparing'
11  | 'pending'
12  | 'running'
13  | 'scheduled'
14  | 'manual'
15  | 'success'
16  | 'failed'
17  | 'canceled'
18  | 'skipped'
19
20interface ApiMergeRequest {
21  iid: number
22  project_id: number
23  state: ApiMergeRequestState
24  draft?: boolean
25  target_branch: string
26  web_url: string
27}
28
29interface ApiPipeline {
30  id: number
31  status: ApiPipelineStatus
32  sha: string
33  web_url: string
34}
35
36interface ApiMergeRequestDetails {
37  sha: string
38  has_conflicts: boolean
39  head_pipeline: ApiPipeline | null
40}
41
42const listLimit = 20
43
44export const notInstalledMessage = 'glab is not installed - see https://gitlab.com/gitlab-org/cli#installation'
45export const notLoggedInMessage = 'glab is not logged in to this GitLab host - run: glab auth login'
46
47const notLoggedInOutput = /glab auth login|401 Unauthorized/i
48const bannerLine = /^ERROR$/
49
50const states: Record<ApiMergeRequestState, MergeRequestState> = {
51  opened: 'open',
52  merged: 'merged',
53  closed: 'closed',
54  locked: 'closed',
55}
56
57const pipelineStatuses: Record<ApiPipelineStatus, PipelineStatus> = {
58  created: 'running',
59  waiting_for_resource: 'running',
60  preparing: 'running',
61  pending: 'running',
62  running: 'running',
63  scheduled: 'running',
64  manual: 'idle',
65  success: 'passed',
66  failed: 'failed',
67  canceled: 'canceled',
68  skipped: 'idle',
69}
70
71export function describeFailure(output: string): string {
72  if (notLoggedInOutput.test(output)) return notLoggedInMessage
73
74  const lines = output.split('\n').map(line => line.trim())
75  return lines.find(line => line !== '' && !bannerLine.test(line)) ?? 'glab failed'
76}
77
78export function listArgv(sourceBranch: string): string[] {
79  return [
80    'glab',
81    'mr',
82    'list',
83    '--source-branch',
84    sourceBranch,
85    '--all',
86    '--output',
87    'json',
88    '--per-page',
89    String(listLimit),
90  ]
91}
92
93export function detailsArgv(mr: MergeRequest): string[] {
94  return ['glab', 'api', `projects/${mr.project}/merge_requests/${String(mr.number)}`]
95}
96
97export function parseMergeRequests(json: string): MergeRequest[] {
98  const listed = JSON.parse(json) as ApiMergeRequest[]
99  return listed.map(mr => ({
100    number: mr.iid,
101    project: String(mr.project_id),
102    state: states[mr.state],
103    isDraft: mr.draft ?? false,
104    targetBranch: mr.target_branch,
105    url: mr.web_url,
106    pipeline: null,
107    hasConflicts: false,
108  }))
109}
110
111export function pipelineStatus(status: ApiPipelineStatus): PipelineStatus {
112  return pipelineStatuses[status]
113}
114
115export function parseDetails(json: string): MergeRequestDetails {
116  const details = JSON.parse(json) as ApiMergeRequestDetails
117  const pipeline = details.head_pipeline
118  return {
119    sha: details.sha,
120    hasConflicts: details.has_conflicts,
121    headPipeline: pipeline && {
122      status: pipelineStatus(pipeline.status),
123      id: String(pipeline.id),
124      sha: pipeline.sha,
125      url: pipeline.web_url,
126    },
127  }
128}
129
130export function investigationPrompt(mr: FailedMergeRequest, sourceBranch: string): string {
131  const { project, pipeline } = mr
132  const failedJobs = `glab api 'projects/${project}/pipelines/${pipeline.id}/jobs?scope=failed'`
133  const jobLog = `glab api projects/${project}/jobs/<job-id>/trace`
134
135  return [
136    `The GitLab pipeline ${pipeline.url} of MR !${String(mr.number)} (${sourceBranch} → ${mr.targetBranch}) just failed.`,
137    `Investigate why: list its failed jobs with \`${failedJobs}\` and read each job's log with \`${jobLog}\`.`,
138    'Report the root cause and the files or tests involved.',
139    'Investigate only: do not change code, commit, push or retry the pipeline.',
140  ].join('\n')
141}
142
143export const gitlab: Forge = {
144  name: 'gitlab',
145  cli: 'glab',
146  noun: 'MR',
147  sigil: '!',
148  logo: '\u{f0ba0} ',
149  notInstalledMessage,
150  notLoggedInMessage,
151  listArgv,
152  parseMergeRequests,
153  detailsArgv,
154  parseDetails,
155  describeFailure,
156  investigationPrompt,
157}
158
types/index.d.ts 41 lines
1export type ForgeName = 'gitlab' | 'github'
2
3export type IconStyle = 'nerd-font' | 'text'
4
5export type MergeRequestState = 'open' | 'merged' | 'closed'
6
7export type PipelineStatus = 'running' | 'passed' | 'failed' | 'canceled' | 'idle'
8
9export interface ResolvedPipeline {
10  status: PipelineStatus
11  id: string
12  url: string
13}
14
15export type Pipeline = ResolvedPipeline | { status: 'awaiting' }
16
17export interface MergeRequest {
18  number: number
19  project: string
20  state: MergeRequestState
21  isDraft: boolean
22  targetBranch: string
23  url: string
24  pipeline: Pipeline | null
25  hasConflicts: boolean
26}
27
28export interface BranchStatus {
29  repo: string | null
30  branch: string | null
31  forge: ForgeName | null
32  mergeRequests: MergeRequest[] | null
33  error: string | null
34}
35
36declare module 'claude-code' {
37  interface PluginState {
38    'merge-status': { branchStatus: BranchStatus; isVisible: boolean }
39  }
40}
41