GitLab workflow best practices and glab CLI usage

GitLab workflow best practices and glab CLI usage for Claude Code.
.gitlab-ci.yml authoring and lintingci-monitor)glab api --jq, gh pr on a GitLab remote, escaped-$ inline GraphQL, hallucinated mutations) with the corrected form, and nudges loading gitlab:merge-request before hand-rolled MR transactionsregister.ts watches the current branch's merge request and shows its pipeline and approval state at the end of the prompt footer. A newly failed job or any new approval, unapproval, request for changes, or comment from someone else reaches the model, and everything is logged to mod-events. Needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 and a signed-in glabbun test plugins/gitlab
bun scripts/mod-test.ts gitlabmod/register.ts 360 lines1import type { EngineInterface, On } from "claude-code";
2import {
3 STALE,
4 type Job,
5 type Promoted,
6 type Snapshot,
7 decide,
8 isOpen,
9 messageOf,
10 parseApprovals,
11 parseJobs,
12 parseNotes,
13 parseUser,
14 parseView,
15 snapshotOf,
16 statusOf,
17} from "./mr.ts";
18
19const MOD = "gitlab";
20
21export const POLL_MS = {
22 pending: 60_000,
23 settled: 180_000,
24 idle: 120_000,
25 closed: 600_000,
26 error: 300_000,
27} as const;
28
29// A dropped injection is retried on later polls, up to this many attempts.
30export const MAX_ATTEMPTS = 3;
31// Fallback default branches when origin/HEAD is unset.
32const DEFAULT_BRANCHES = new Set(["main", "master"]);
33// /clear and /resume keep the process running, so the watch continues.
34const CONTINUING = new Set(["clear", "resume"]);
35
36interface Undelivered {
37 head: string;
38 decisions: Promoted[];
39 attempts: number;
40}
41
42interface Watch {
43 snapshot: Snapshot | undefined;
44 branch: string | undefined;
45 me: string | undefined;
46 undelivered: Undelivered | undefined;
47 error: string | undefined;
48 timer: { cancel(): void } | undefined;
49 line: string | undefined;
50 generation: number;
51 isStopped: boolean;
52}
53
54type Read = { kind: "none" } | { kind: "mr"; snapshot: Snapshot; branch: string };
55
56function show($: EngineInterface, watch: Watch, text: string | undefined) {
57 const line = text === "" ? undefined : text;
58 if (line === watch.line) return;
59 watch.line = line;
60 $.ui.invalidate("ui.render");
61}
62
63function emit($: EngineInterface, event: string, detail: Record<string, unknown>, ok = true) {
64 void $.modEvents.emit({ mod: MOD, event, ok, detail });
65}
66
67async function isDefaultBranch($: EngineInterface, cwd: string, branch: string): Promise<boolean> {
68 const origin = await $.process.run(
69 ["git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
70 { cwd },
71 );
72 if (origin.exitCode !== 0) return DEFAULT_BRANCHES.has(branch);
73 return origin.stdout.trim() === `origin/${branch}`;
74}
75
76const NO_MR = /no (open )?merge request/i;
77
78// glab resolves the host from the git remote, which covers self-managed instances.
79async function glab($: EngineInterface, cwd: string, args: string[]): Promise<string> {
80 const result = await $.process.run(["glab", ...args], { cwd, timeoutMs: 20_000 });
81 if (result.exitCode === 0) return result.stdout;
82 // glab opens its stderr with a styled ERROR banner line ahead of the cause.
83 const line = result.stderr
84 .split("\n")
85 .map((text) => text.trim())
86 .find((text) => text !== "" && text !== "ERROR");
87 throw new Error(line ?? `glab exited ${result.exitCode}`);
88}
89
90function parsed<T>(value: T | undefined, source: string): T {
91 if (value === undefined) throw new Error(`${source} returned an unexpected shape`);
92 return value;
93}
94
95// glab mr view finds only an open MR by branch, so a tracked MR is re-read by IID
96// to see it merge or close.
97async function viewMr(
98 $: EngineInterface,
99 watch: Watch,
100 cwd: string,
101 branch: string,
102): Promise<string | undefined> {
103 try {
104 return await glab($, cwd, ["mr", "view", "-F", "json"]);
105 } catch (error) {
106 if (!(error instanceof Error && NO_MR.test(error.message))) throw error;
107 if (watch.snapshot === undefined || watch.branch !== branch) return undefined;
108 return glab($, cwd, ["mr", "view", String(watch.snapshot.mr), "-F", "json"]);
109 }
110}
111
112async function jobsOf($: EngineInterface, cwd: string, projectId: number, pipeline: number) {
113 const path = `projects/${projectId}/pipelines/${pipeline}/jobs?per_page=100`;
114 const stdout = await glab($, cwd, ["api", "--paginate", "--output", "ndjson", path]);
115 return parsed(parseJobs(stdout), "pipeline jobs");
116}
117
118async function meOf($: EngineInterface, watch: Watch, cwd: string): Promise<string> {
119 if (watch.me !== undefined) return watch.me;
120 watch.me = parsed(parseUser(await glab($, cwd, ["api", "user"])), "glab api user");
121 return watch.me;
122}
123
124async function readMr($: EngineInterface, watch: Watch, isRetry = false): Promise<Read> {
125 const cwd = await $.session.cwd();
126 const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd });
127 const branch = head.stdout.trim();
128 if (head.exitCode !== 0 || branch === "HEAD" || (await isDefaultBranch($, cwd, branch))) {
129 return { kind: "none" };
130 }
131 const stdout = await viewMr($, watch, cwd, branch);
132 if (stdout === undefined) return { kind: "none" };
133 const view = parsed(parseView(stdout), "glab mr view");
134 const prev = watch.snapshot;
135 // A merged or closed MR changes nothing else the watch shows.
136 if (prev?.mr === view.iid && !isOpen(prev) && view.state === prev.state) {
137 return { kind: "mr", snapshot: prev, branch };
138 }
139 const mr = `projects/${view.projectId}/merge_requests/${view.iid}`;
140 const pipeline = view.pipeline;
141 const [approvals, notes, jobs, me] = await Promise.all([
142 glab($, cwd, ["api", `${mr}/approvals`]),
143 glab($, cwd, ["api", `${mr}/notes?sort=desc&order_by=created_at&per_page=100`]),
144 pipeline === null
145 ? Promise.resolve<Job[]>([])
146 : jobsOf($, cwd, pipeline.projectId, pipeline.id),
147 meOf($, watch, cwd),
148 ]);
149 const read = snapshotOf({
150 view,
151 approvals: parsed(parseApprovals(approvals), "MR approvals"),
152 notes: parsed(parseNotes(notes), "MR notes"),
153 jobs,
154 me,
155 });
156 // A deleted note can lower the newest id seen, which would re-admit older notes.
157 const snapshot =
158 prev?.mr === read.mr
159 ? { ...read, noteCursor: Math.max(prev.noteCursor, read.noteCursor) }
160 : read;
161 // The reads are not atomic, so approvals a push reset can look withdrawn on the
162 // head read before it.
163 if (
164 !isRetry &&
165 prev?.mr === snapshot.mr &&
166 prev.head === snapshot.head &&
167 prev.approvers.some((name) => !snapshot.approvers.includes(name))
168 ) {
169 const recheck = parsed(
170 parseView(await glab($, cwd, ["mr", "view", String(view.iid), "-F", "json"])),
171 "glab mr view",
172 );
173 if (recheck.sha !== view.sha) return readMr($, watch, true);
174 }
175 return { kind: "mr", snapshot, branch };
176}
177
178async function inject(
179 $: EngineInterface,
180 snapshot: Snapshot,
181 promoted: Promoted[],
182 attempt: number,
183): Promise<boolean> {
184 const text = messageOf(snapshot, promoted);
185 const kinds = promoted.map((d) => d.kind);
186 const detail = { mr: snapshot.mr, head: snapshot.head, kinds, chars: text.length, attempt };
187 try {
188 const result = await $.prompt.submit({ text });
189 if (result.drop !== undefined) {
190 emit($, "inject", { ...detail, dropped: result.drop }, false);
191 return false;
192 }
193 emit($, "inject", detail);
194 return true;
195 } catch (error) {
196 emit($, "inject", { ...detail, error: String(error) }, false);
197 return false;
198 }
199}
200
201function stillDue(undelivered: Undelivered | undefined, next: Snapshot): Promoted[] {
202 if (undelivered === undefined || !isOpen(next)) return [];
203 return undelivered.decisions.flatMap((decision): Promoted[] => {
204 if (decision.kind !== "ci.failed") return [decision];
205 if (undelivered.head !== next.head) return [];
206 const failed = decision.failed.filter((label) => next.failed.includes(label));
207 return failed.length > 0 ? [{ ...decision, failed }] : [];
208 });
209}
210
211function merge(carried: Promoted[], fresh: Promoted[]): Promoted[] {
212 const failed = [...carried, ...fresh].flatMap((d) => (d.kind === "ci.failed" ? d.failed : []));
213 const reviews = [...carried, ...fresh].filter((d) => d.kind !== "ci.failed");
214 if (failed.length === 0) return reviews;
215 return [
216 { action: "promote", kind: "ci.failed", failed: [...new Set(failed)].toSorted() },
217 ...reviews,
218 ];
219}
220
221function delayOf(snapshot: Snapshot): number {
222 if (!isOpen(snapshot)) return POLL_MS.closed;
223 return snapshot.phase === "pending" ? POLL_MS.pending : POLL_MS.settled;
224}
225
226// Resolves undefined when the watch stopped or restarted during the send.
227async function apply(
228 $: EngineInterface,
229 watch: Watch,
230 read: Read,
231 isCurrent: () => boolean,
232): Promise<number | undefined> {
233 if (read.kind === "none") {
234 show($, watch, undefined);
235 watch.snapshot = undefined;
236 watch.branch = undefined;
237 watch.undelivered = undefined;
238 return POLL_MS.idle;
239 }
240 const next = read.snapshot;
241 const prev = watch.snapshot;
242 if (prev === undefined || prev.mr !== next.mr || prev.projectId !== next.projectId) {
243 emit($, "mr.tracked", { mr: next.mr, head: next.head, phase: next.phase, state: next.state });
244 watch.undelivered = undefined;
245 } else {
246 const decisions = decide(prev, next);
247 for (const { action, ...detail } of decisions) {
248 emit($, action, { mr: next.mr, head: next.head, ...detail });
249 }
250 const fresh = decisions.filter((d): d is Promoted => d.action === "promote");
251 const due = merge(stillDue(watch.undelivered, next), fresh);
252 const attempt = fresh.length > 0 ? 1 : (watch.undelivered?.attempts ?? 0) + 1;
253 watch.undelivered = undefined;
254 if (due.length > 0) {
255 const isSent = await inject($, next, due, attempt);
256 if (!isCurrent()) return undefined;
257 if (!isSent && attempt < MAX_ATTEMPTS) {
258 watch.undelivered = { head: next.head, decisions: due, attempts: attempt };
259 }
260 }
261 }
262 watch.snapshot = next;
263 watch.branch = read.branch;
264 show($, watch, statusOf(next));
265 return delayOf(next);
266}
267
268async function poll($: EngineInterface, watch: Watch, generation: number): Promise<void> {
269 const isCurrent = () => !watch.isStopped && watch.generation === generation;
270 if (!isCurrent()) return;
271 let delay: number;
272 try {
273 const read = await readMr($, watch);
274 // A session.end that landed while glab ran has already cleared the status.
275 if (!isCurrent()) return;
276 const applied = await apply($, watch, read, isCurrent);
277 if (applied === undefined) return;
278 delay = applied;
279 watch.error = undefined;
280 } catch (error) {
281 const message = error instanceof Error ? error.message : String(error);
282 if (message !== watch.error) emit($, "poll.error", { error: message }, false);
283 watch.error = message;
284 delay = POLL_MS.error;
285 if (watch.snapshot !== undefined && isCurrent()) {
286 const line = statusOf(watch.snapshot);
287 show($, watch, line === "" ? STALE : `${line} · ${STALE}`);
288 }
289 }
290 if (!isCurrent()) return;
291 watch.timer = $.clock.after(delay, () => void poll($, watch, generation));
292}
293
294async function hasGlab($: EngineInterface): Promise<boolean> {
295 try {
296 return (await $.process.run(["glab", "--version"], { timeoutMs: 5000 })).exitCode === 0;
297 } catch {
298 // run() rejects when glab is not on PATH.
299 return false;
300 }
301}
302
303/**
304 * Watches the current branch's merge request with `glab` and shows its pipeline
305 * and approval state at the end of the prompt footer. A newly failed job or any
306 * new approval, unapproval, request for changes, or comment from someone else is sent to the model.
307 * Every other change is only shown and logged.
308 */
309export function register(on: On): void {
310 const watch: Watch = {
311 snapshot: undefined,
312 branch: undefined,
313 me: undefined,
314 undelivered: undefined,
315 error: undefined,
316 timer: undefined,
317 line: undefined,
318 generation: 0,
319 isStopped: true,
320 };
321
322 on("session.start", async ($, e, next) => {
323 const generation = watch.generation;
324 const started = await next(e);
325 if (!e.isInteractive) {
326 emit($, "session.start", { active: false, reason: "non-interactive" });
327 return started;
328 }
329 if (!(await hasGlab($))) {
330 emit($, "session.start", { active: false, reason: "glab unavailable" });
331 return started;
332 }
333 // A session.end while this start was in flight has moved the generation on.
334 if (watch.generation !== generation) return started;
335 emit($, "session.start", { active: true });
336 if (watch.isStopped) {
337 watch.isStopped = false;
338 watch.generation += 1;
339 void poll($, watch, watch.generation);
340 }
341 return started;
342 });
343
344 on("ui.render", { component: "PromptHint" }, ($, e, next) => {
345 if (watch.line === undefined) return next(e);
346 const tail = e.props.tail === undefined ? watch.line : `${e.props.tail} · ${watch.line}`;
347 return next({ ...e, props: { ...e.props, tail } });
348 });
349
350 on("session.end", ($, e, next) => {
351 if (!CONTINUING.has(e.reason)) {
352 watch.isStopped = true;
353 watch.generation += 1;
354 watch.timer?.cancel();
355 show($, watch, undefined);
356 }
357 return next(e);
358 });
359}
360mod/mr.ts 370 lines1type Reader<T> = (value: unknown) => T | undefined;
2type Out<R> = R extends Reader<infer T> ? T : never;
3type Shaped<S> = { [K in keyof S]: Out<S[K]> };
4
5const isRecord = (value: unknown): value is Record<string, unknown> =>
6 typeof value === "object" && value !== null && !Array.isArray(value);
7
8const str: Reader<string> = (value) => (typeof value === "string" ? value : undefined);
9const num: Reader<number> = (value) => (typeof value === "number" ? value : undefined);
10const bool: Reader<boolean> = (value) => (typeof value === "boolean" ? value : undefined);
11
12const or =
13 <T, F>(read: Reader<T>, fallback: F): Reader<T | F> =>
14 (value) =>
15 read(value) ?? fallback;
16
17const map =
18 <T, U>(read: Reader<T>, to: (value: T) => U): Reader<U> =>
19 (value) => {
20 const result = read(value);
21 return result === undefined ? undefined : to(result);
22 };
23
24const list =
25 <T>(item: Reader<T>): Reader<T[]> =>
26 (value) =>
27 Array.isArray(value)
28 ? value.map(item).filter((entry): entry is T => entry !== undefined)
29 : undefined;
30
31function obj<S extends Record<string, Reader<unknown>>>(shape: S): Reader<Shaped<S>> {
32 // Every field holds its own reader's output, so a full read fits the shape.
33 const isRead = (out: Record<string, unknown>): out is Shaped<S> =>
34 Object.keys(shape).every((key) => out[key] !== undefined);
35 return (value) => {
36 if (!isRecord(value)) return undefined;
37 const out = Object.fromEntries(
38 Object.entries(shape).map(([key, read]) => [key, read(value[key])]),
39 );
40 return isRead(out) ? out : undefined;
41 };
42}
43
44function decoded<T>(stdout: string, read: Reader<T>): T | undefined {
45 let value: unknown;
46 try {
47 value = JSON.parse(stdout);
48 } catch {
49 // The caller reports malformed output as an unexpected shape.
50 return undefined;
51 }
52 return read(value);
53}
54
55const readUsername = map(obj({ username: str }), (user) => user.username);
56
57const readPipeline = map(obj({ id: num, project_id: num, status: str }), (p) => ({
58 id: p.id,
59 projectId: p.project_id,
60 status: p.status,
61}));
62
63const readView = map(
64 obj({
65 iid: num,
66 project_id: num,
67 web_url: str,
68 state: str,
69 sha: str,
70 detailed_merge_status: or(str, null),
71 head_pipeline: or(readPipeline, null),
72 }),
73 (v) => ({
74 iid: v.iid,
75 projectId: v.project_id,
76 url: v.web_url,
77 state: v.state,
78 sha: v.sha,
79 detailedMergeStatus: v.detailed_merge_status,
80 pipeline: v.head_pipeline,
81 }),
82);
83
84const readApprovals = map(
85 obj({
86 approvals_required: or(num, 0),
87 approvals_left: or(num, 0),
88 approved_by: list(map(obj({ user: readUsername }), (entry) => entry.user)),
89 }),
90 (a) => ({
91 approvalsRequired: a.approvals_required,
92 approvalsLeft: a.approvals_left,
93 approvedBy: a.approved_by,
94 }),
95);
96
97const readNote = map(
98 obj({ id: num, author: or(readUsername, "someone"), system: or(bool, false) }),
99 (n) => ({ id: n.id, author: n.author, isSystem: n.system }),
100);
101
102const readJob = map(
103 obj({ id: num, name: or(str, "job"), status: str, allow_failure: or(bool, false) }),
104 (j) => ({ id: j.id, name: j.name, status: j.status, allowFailure: j.allow_failure }),
105);
106
107export type MrView = Out<typeof readView>;
108export type Approvals = Out<typeof readApprovals>;
109export type Note = Out<typeof readNote>;
110export type Job = Out<typeof readJob>;
111
112/** Narrows `glab mr view -F json` output to an MrView, or undefined when it is not one. */
113export const parseView = (stdout: string): MrView | undefined => decoded(stdout, readView);
114export const parseApprovals = (stdout: string): Approvals | undefined =>
115 decoded(stdout, readApprovals);
116export const parseNotes = (stdout: string): Note[] | undefined => decoded(stdout, list(readNote));
117// `glab api --paginate --output ndjson` prints one job per line across every page.
118export const parseJobs = (stdout: string): Job[] | undefined =>
119 decoded(`[${stdout.split("\n").filter(Boolean).join(",")}]`, list(readJob));
120export const parseUser = (stdout: string): string | undefined => decoded(stdout, readUsername);
121
122export type Phase = "none" | "pending" | "passing" | "failing";
123
124export interface Snapshot {
125 mr: number;
126 projectId: number;
127 url: string;
128 state: string;
129 head: string;
130 pipeline: number | undefined;
131 phase: Phase;
132 total: number;
133 pending: number;
134 failed: string[];
135 isChangesRequested: boolean;
136 isApproved: boolean;
137 approvalsLeft: number;
138 approvers: string[];
139 noteCursor: number;
140 notes: { id: number; author: string }[];
141}
142
143export type Decision =
144 | { action: "promote"; kind: "ci.failed"; failed: string[] }
145 | { action: "promote"; kind: "review"; author: string; state: "approved" | "unapproved" }
146 | { action: "promote"; kind: "review"; author: string; state: "commented"; notes: number }
147 | { action: "promote"; kind: "review"; state: "changes_requested" }
148 | { action: "drop"; kind: "ci.passing" | "ci.pending" | "ci.none" }
149 | { action: "drop"; kind: "push"; from: string }
150 | { action: "drop"; kind: "approvals.reset"; authors: string[] }
151 | { action: "drop"; kind: "mr.closed" | "mr.merged" };
152
153export type Promoted = Extract<Decision, { action: "promote" }>;
154
155// A locked MR is mid-merge and still open.
156const OPEN = new Set(["opened", "locked"]);
157export const isOpen = (snapshot: Snapshot): boolean => OPEN.has(snapshot.state);
158
159const PENDING = new Set([
160 "created",
161 "waiting_for_resource",
162 "preparing",
163 "pending",
164 "running",
165 "scheduled",
166]);
167
168// A pipeline is manual only when a blocking manual job holds it, while a
169// non-blocking manual job sits in a finished pipeline.
170const PIPELINE_PENDING = new Set([...PENDING, "manual"]);
171
172const LABEL_MAX = 80;
173
174// Anyone who can edit .gitlab-ci.yml names its jobs, and the label reaches the model.
175function flatten(text: string): string {
176 const flat = text
177 .replaceAll(/[\p{Cc}\u2028\u2029]/gu, " ")
178 .replaceAll(/\s+/g, " ")
179 .trim();
180 return flat === "" ? "job" : flat;
181}
182
183function shown(label: string): string {
184 return label.length > LABEL_MAX ? `${label.slice(0, LABEL_MAX - 1)}…` : label;
185}
186
187export interface Read {
188 view: MrView;
189 approvals: Approvals;
190 notes: Note[];
191 jobs: Job[];
192 me: string;
193}
194
195function phaseOf(read: Read, failed: string[], pending: number): Phase {
196 const pipeline = read.view.pipeline;
197 if (pipeline === null) return "none";
198 if (failed.length > 0) return "failing";
199 if (pending > 0 || PIPELINE_PENDING.has(pipeline.status)) return "pending";
200 if (pipeline.status === "canceled" || pipeline.status === "skipped") return "none";
201 return "passing";
202}
203
204export function snapshotOf(read: Read): Snapshot {
205 const { view, approvals, notes, jobs, me } = read;
206 const failed: string[] = [];
207 let pending = 0;
208 const isBlocked = view.pipeline?.status === "manual";
209 for (const job of jobs) {
210 if (job.status === "failed" && !job.allowFailure) failed.push(flatten(job.name));
211 else if (PENDING.has(job.status) || (isBlocked && job.status === "manual")) pending += 1;
212 }
213 // A failed downstream pipeline fails its trigger job, which the jobs list leaves out.
214 if (view.pipeline?.status === "failed" && failed.length === 0) failed.push("pipeline");
215 failed.sort();
216 return {
217 mr: view.iid,
218 projectId: view.projectId,
219 url: view.url,
220 state: view.state,
221 head: view.sha,
222 pipeline: view.pipeline?.id,
223 phase: phaseOf(read, failed, pending),
224 total: jobs.length,
225 pending,
226 failed: [...new Set(failed)],
227 isChangesRequested: view.detailedMergeStatus === "requested_changes",
228 isApproved: approvals.approvedBy.length > 0 && approvals.approvalsLeft === 0,
229 approvalsLeft: approvals.approvalsLeft,
230 approvers: approvals.approvedBy.filter((name) => name !== me).toSorted(),
231 noteCursor: Math.max(0, ...notes.map((n) => n.id)),
232 notes: notes
233 .filter((n) => !n.isSystem && n.author !== me)
234 .map(({ id, author }) => ({ id, author })),
235 };
236}
237
238function reviewsOf(prev: Snapshot, next: Snapshot, isSameHead: boolean): Decision[] {
239 const decisions: Decision[] = [];
240 const before = new Set(prev.approvers);
241 const after = new Set(next.approvers);
242 for (const author of next.approvers) {
243 if (!before.has(author))
244 decisions.push({ action: "promote", kind: "review", author, state: "approved" });
245 }
246 const removed = prev.approvers.filter((author) => !after.has(author));
247 // A push can reset approvals, which no reviewer did.
248 if (removed.length > 0 && !isSameHead) {
249 decisions.push({ action: "drop", kind: "approvals.reset", authors: removed });
250 } else {
251 for (const author of removed) {
252 decisions.push({ action: "promote", kind: "review", author, state: "unapproved" });
253 }
254 }
255
256 // GitLab names no reviewer for a request for changes.
257 if (next.isChangesRequested && !prev.isChangesRequested) {
258 decisions.push({ action: "promote", kind: "review", state: "changes_requested" });
259 }
260
261 const byAuthor = new Map<string, number>();
262 for (const note of next.notes.toSorted((a, b) => a.id - b.id)) {
263 if (note.id > prev.noteCursor) byAuthor.set(note.author, (byAuthor.get(note.author) ?? 0) + 1);
264 }
265 for (const [author, notes] of byAuthor) {
266 decisions.push({ action: "promote", kind: "review", author, state: "commented", notes });
267 }
268 return decisions;
269}
270
271/**
272 * What changed between two polls of the same MR. A job that newly fails, or
273 * any new approval, unapproval, request for changes, or comment from someone
274 * else, is promoted.
275 * Every other change is dropped.
276 */
277export function decide(prev: Snapshot, next: Snapshot): Decision[] {
278 if (!isOpen(next)) {
279 if (!isOpen(prev)) return [];
280 return [{ action: "drop", kind: next.state === "merged" ? "mr.merged" : "mr.closed" }];
281 }
282 const decisions: Decision[] = [];
283 const isSameHead = prev.head === next.head;
284 if (!isSameHead) decisions.push({ action: "drop", kind: "push", from: prev.head });
285
286 const known = new Set(isSameHead ? prev.failed : []);
287 const failed = next.failed.filter((label) => !known.has(label));
288 if (failed.length > 0) decisions.push({ action: "promote", kind: "ci.failed", failed });
289 else if (next.phase !== prev.phase || !isSameHead) {
290 if (next.phase === "passing") decisions.push({ action: "drop", kind: "ci.passing" });
291 else if (next.phase === "pending") decisions.push({ action: "drop", kind: "ci.pending" });
292 else if (next.phase === "none") decisions.push({ action: "drop", kind: "ci.none" });
293 }
294
295 decisions.push(...reviewsOf(prev, next, isSameHead));
296 return decisions;
297}
298
299const short = (sha: string) => sha.slice(0, 7);
300
301function listed(labels: string[], max = 3): string {
302 const head = labels.slice(0, max).map(shown).join(", ");
303 return labels.length > max ? `${head} (+${labels.length - max})` : head;
304}
305
306function reviewLineOf(mr: string, decision: Extract<Promoted, { kind: "review" }>): string {
307 if (decision.state === "changes_requested") return `Changes were requested on ${mr}`;
308 if (decision.state === "commented") {
309 const comments = decision.notes === 1 ? "1 new comment" : `${decision.notes} new comments`;
310 return `@${decision.author} commented on ${mr} (${comments})`;
311 }
312 if (decision.state === "approved") return `@${decision.author} approved ${mr}`;
313 return `@${decision.author} revoked their approval of ${mr}`;
314}
315
316function paragraphOf(snapshot: Snapshot, decision: Promoted): string {
317 const mr = `MR !${snapshot.mr}`;
318 if (decision.kind === "ci.failed") {
319 return [
320 `Pipeline failed on ${mr} at ${short(snapshot.head)}: ${decision.failed.map(shown).join(", ")}.`,
321 `Read the failure with \`glab ci get --merge-request=${snapshot.mr} --status=failed\` and \`glab ci trace <job> --pipeline-id ${snapshot.pipeline}\`.`,
322 ].join("\n");
323 }
324 return [
325 `${reviewLineOf(mr, decision)}: ${snapshot.url}`,
326 `Read the discussion with \`glab mr view ${snapshot.mr} --comments\`.`,
327 ].join("\n");
328}
329
330export function messageOf(snapshot: Snapshot, promoted: Promoted[]): string {
331 const paragraphs = promoted.map((decision) => paragraphOf(snapshot, decision));
332 paragraphs.push(
333 "The gitlab plugin watches this MR's pipeline and reviews, so polling them is unnecessary.",
334 );
335 return paragraphs.join("\n\n");
336}
337
338// Nerd Font Octicons.
339export const STALE = "\u{f421} stale";
340
341const ENDED: Partial<Record<string, string>> = {
342 merged: "\u{f419} merged",
343 closed: "\u{f4dc} closed",
344};
345
346const CHECKS: Record<Phase, (snapshot: Snapshot) => string | undefined> = {
347 none: () => undefined,
348 pending: (s) => `\u{f444} CI ${s.total - s.pending}/${s.total}`,
349 passing: () => "\u{f49e} CI",
350 failing: (s) => `\u{f52f} ${listed(s.failed)}`,
351};
352
353function reviewOf(snapshot: Snapshot): string | undefined {
354 if (snapshot.isChangesRequested) return "\u{f4d2} changes requested";
355 if (snapshot.approvalsLeft > 0) {
356 const left = snapshot.approvalsLeft;
357 return `\u{f441} ${left} ${left === 1 ? "approval" : "approvals"} needed`;
358 }
359 return snapshot.isApproved ? "\u{f42e} approved" : undefined;
360}
361
362// The footer already links the MR, so the state rides after it on that line.
363export function statusOf(snapshot: Snapshot): string {
364 const ended = ENDED[snapshot.state];
365 if (ended !== undefined) return ended;
366 return [CHECKS[snapshot.phase](snapshot), reviewOf(snapshot)]
367 .filter((part) => part !== undefined)
368 .join(" · ");
369}
370