SLOPSHOPPER

gitlab

GitLab workflow best practices and glab CLI usage

newspinnerprocesstimer
★ 18v?MITupdated 2026-10-14bendrucker/claude/plugins/gitlab
A shopper browsing a rack in a slop shop
README

GitLab Plugin

GitLab workflow best practices and glab CLI usage for Claude Code.

Contents

Skills

  • merge-request: Working with merge requests
  • ci-monitor: Watch an MR's CI and stream state events, invoking the logs agent on failures. Also covers .gitlab-ci.yml authoring and linting
  • api: REST and GraphQL API access, plus GitLab docs navigation

Agents

  • logs: Extracts relevant lines from failing-job logs (invoked by ci-monitor)

Hooks

  • Transforms GitLab URLs into API requests for better content fetching
  • Detects glab OAuth token expiration and provides recovery guidance
  • Denies gh-isms that always fail against GitLab (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 transactions

Mod

  • register.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 glab

Testing

bun test plugins/gitlab
bun scripts/mod-test.ts gitlab
Source 2 files
mod/register.ts 360 lines
1import 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}
360
mod/mr.ts 370 lines
1type 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