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

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:

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.
Taken in Claude Code on this repository's demo pull requests, and on a GitLab merge request.
Checks passed on both pull requests:

Checks failed, with a merge conflict on the draft:

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

GitLab merge requests through glab:

No open pull request on the branch:

A setup hint when the CLI is not logged in:

glab, GitHub pull requests through gh, picked from the origin remote./theme recolors the row right away./mr opens one in your browser.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.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 ``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.origin remote on that host. Repositories without an origin remote are ignored.icons to text (see Settings)./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.
glab auth login for GitLab, gh auth login for GitHub (see Requirements). /plugin marketplace add JanSuthacheeva/claude-code-mods /plugin install merge-status@claude-code-mods `` /plugin configure merge-status@claude-code-mods ` and set icons to text`.For development, link a clone of this repository instead:
ln -s "$PWD/claude-code-mods/merge-status" ~/.claude/skills/merge-status
| Sign | Meaning |
|---|---|
!3609 / #128 | MR or PR number, linked to its page |
open / draft | Open MR, ready or draft |
→ develop | Target branch |
✓ ⟳ ✗ ○ | Pipeline passed, running, failed, none |
⚠ | Merge conflict |
MRs are ordered by target branch: feature and bugfix branches first, then develop, sprint-* and main.
| Setting | Values | Default |
|---|---|---|
icons | nerd-font: GitLab or GitHub logo | nerd-font |
text: MR or PR label |
Change it with /plugin configure merge-status@claude-code-mods in Claude Code, then restart.
/mrs hides or shows the row./mr opens the first MR in the browser, /mr 2 the second, /mr 3609 a specific one.Everything runs through glab or gh and local git; nothing reaches the model or costs tokens, except failure investigations.
glab mr create or gh pr create, whether Claude runs it or you do (a local git check runs every 15 seconds).claude plugin validate .
claude plugin test .hooks/register.tsx 330 lines1import { 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}
330hooks/browser.ts 22 lines1export 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}
22hooks/forge.ts 53 lines1import 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}
53hooks/git.ts 36 lines1import 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}
36hooks/merge-requests.ts 24 lines1import 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}
24hooks/pipelines.ts 55 lines1import 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}
55hooks/schedule.ts 55 lines1import 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}
55hooks/status-line.ts 53 lines1import 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}
53hooks/github.ts 180 lines1import 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}
180hooks/gitlab.ts 158 lines1import 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}
158types/index.d.ts 41 lines1export 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