SLOPSHOPPER

github

GitHub workflow, Actions monitoring, and rulesets management

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

GitHub Plugin

GitHub workflow, Actions monitoring, and rulesets management for Claude Code.

Contents

  • Skills:
  • actions-monitor: Watch a PR's CI and stream state events; invokes the logs agent on failures
  • attach: Attach an image or video to an issue, PR, comment, or review, through gh --attach or the user-attachments endpoint it wraps
  • copilot: Cross-model review of the current diff through the Copilot CLI. Slash-invocable only, one call by default and three at most, because the Copilot plan is a fixed budget
  • notifications: Inbox management (list, filter, mark read/done, unsubscribe)
  • pr-comments: Fetch unresolved review comments from a pull request
  • stack: Build, publish, and merge native stacked PRs with the gh stack extension
  • Agents:
  • github-rulesets-manager: Configure repository rulesets and branch protection
  • logs: Extracts relevant lines from failing-job logs (invoked by actions-monitor)
  • Hook: Intercepts WebFetch for efficient GitHub data access
  • Mod: register.ts watches the current branch's pull request and shows its CI and review state at the end of the prompt footer. A newly failed check or any new review, with its state, reaches the model, and everything is logged to mod-events. Needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 and a signed-in gh

Testing

bun test plugins/github
bun scripts/mod-test.ts github
Source 2 files
mod/register.ts 265 lines
1import type { EngineInterface, On } from "claude-code";
2import {
3  STALE,
4  type Promoted,
5  type Snapshot,
6  VIEW_FIELDS,
7  decide,
8  messageOf,
9  parseView,
10  snapshotOf,
11  statusOf,
12} from "./pr.ts";
13
14const MOD = "github";
15
16export const POLL_MS = {
17  pending: 60_000,
18  settled: 180_000,
19  idle: 120_000,
20  closed: 600_000,
21  error: 300_000,
22} as const;
23
24// A dropped injection is retried on later polls, up to this many attempts.
25export const MAX_ATTEMPTS = 3;
26// Skipped when origin/HEAD is unset and the real default branch is unknown.
27const DEFAULT_BRANCHES = new Set(["main", "master"]);
28// /clear and /resume keep the process running, so the watch continues.
29const CONTINUING = new Set(["clear", "resume"]);
30
31interface Undelivered {
32  head: string;
33  decisions: Promoted[];
34  attempts: number;
35}
36
37interface Watch {
38  snapshot: Snapshot | undefined;
39  undelivered: Undelivered | undefined;
40  error: string | undefined;
41  timer: { cancel(): void } | undefined;
42  line: string | undefined;
43  generation: number;
44  isStopped: boolean;
45}
46
47type Read = { kind: "none" } | { kind: "pr"; snapshot: Snapshot };
48
49function show($: EngineInterface, watch: Watch, text: string | undefined) {
50  const line = text === "" ? undefined : text;
51  if (line === watch.line) return;
52  watch.line = line;
53  $.ui.invalidate("ui.render");
54}
55
56function emit($: EngineInterface, event: string, detail: Record<string, unknown>, ok = true) {
57  void $.modEvents.emit({ mod: MOD, event, ok, detail });
58}
59
60async function isDefaultBranch($: EngineInterface, cwd: string, branch: string): Promise<boolean> {
61  const origin = await $.process.run(
62    ["git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
63    { cwd },
64  );
65  if (origin.exitCode !== 0) return DEFAULT_BRANCHES.has(branch);
66  return origin.stdout.trim() === `origin/${branch}`;
67}
68
69async function readPr($: EngineInterface): Promise<Read> {
70  const cwd = await $.session.cwd();
71  const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd });
72  const branch = head.stdout.trim();
73  if (head.exitCode !== 0 || branch === "HEAD" || (await isDefaultBranch($, cwd, branch))) {
74    return { kind: "none" };
75  }
76  const view = await $.process.run(["gh", "pr", "view", "--json", VIEW_FIELDS], {
77    cwd,
78    timeoutMs: 20_000,
79  });
80  if (view.exitCode !== 0) {
81    if (/no pull requests found/i.test(view.stderr)) return { kind: "none" };
82    const line = view.stderr.trim().split("\n")[0] ?? "";
83    throw new Error(line === "" ? `gh exited ${view.exitCode}` : line);
84  }
85  const parsed = parseView(view.stdout);
86  if (parsed === undefined) throw new Error("gh pr view returned an unexpected shape");
87  return { kind: "pr", snapshot: snapshotOf(parsed) };
88}
89
90async function inject(
91  $: EngineInterface,
92  snapshot: Snapshot,
93  promoted: Promoted[],
94  attempt: number,
95): Promise<boolean> {
96  const text = messageOf(snapshot, promoted);
97  const kinds = promoted.map((d) => d.kind);
98  const detail = { pr: snapshot.pr, head: snapshot.head, kinds, chars: text.length, attempt };
99  try {
100    const result = await $.prompt.submit({ text });
101    if (result.drop !== undefined) {
102      emit($, "inject", { ...detail, dropped: result.drop }, false);
103      return false;
104    }
105    emit($, "inject", detail);
106    return true;
107  } catch (error) {
108    emit($, "inject", { ...detail, error: String(error) }, false);
109    return false;
110  }
111}
112
113// What a dropped injection still has to say: failures only on the same head
114// and only for checks still failing, reviews only while the PR is open.
115function stillDue(undelivered: Undelivered | undefined, next: Snapshot): Promoted[] {
116  if (undelivered === undefined || next.state !== "OPEN") return [];
117  return undelivered.decisions.flatMap((decision): Promoted[] => {
118    if (decision.kind !== "ci.failed") return [decision];
119    if (undelivered.head !== next.head) return [];
120    const failed = decision.failed.filter((label) => next.failed.includes(label));
121    return failed.length > 0 ? [{ ...decision, failed }] : [];
122  });
123}
124
125function merge(carried: Promoted[], fresh: Promoted[]): Promoted[] {
126  const failed = [...carried, ...fresh].flatMap((d) => (d.kind === "ci.failed" ? d.failed : []));
127  const reviews = [...carried, ...fresh].filter((d) => d.kind !== "ci.failed");
128  if (failed.length === 0) return reviews;
129  return [
130    { action: "promote", kind: "ci.failed", failed: [...new Set(failed)].toSorted() },
131    ...reviews,
132  ];
133}
134
135function delayOf(snapshot: Snapshot): number {
136  if (snapshot.state !== "OPEN") return POLL_MS.closed;
137  return snapshot.phase === "pending" ? POLL_MS.pending : POLL_MS.settled;
138}
139
140// Resolves undefined when the watch stopped or restarted during the send.
141async function apply(
142  $: EngineInterface,
143  watch: Watch,
144  read: Read,
145  isCurrent: () => boolean,
146): Promise<number | undefined> {
147  if (read.kind === "none") {
148    show($, watch, undefined);
149    watch.snapshot = undefined;
150    watch.undelivered = undefined;
151    return POLL_MS.idle;
152  }
153  const next = read.snapshot;
154  const prev = watch.snapshot;
155  if (prev === undefined || prev.pr !== next.pr) {
156    emit($, "pr.tracked", { pr: next.pr, head: next.head, phase: next.phase, state: next.state });
157    watch.undelivered = undefined;
158  } else {
159    const decisions = decide(prev, next);
160    for (const { action, ...detail } of decisions) {
161      emit($, action, { pr: next.pr, head: next.head, ...detail });
162    }
163    const fresh = decisions.filter((d): d is Promoted => d.action === "promote");
164    const due = merge(stillDue(watch.undelivered, next), fresh);
165    const attempt = fresh.length > 0 ? 1 : (watch.undelivered?.attempts ?? 0) + 1;
166    watch.undelivered = undefined;
167    if (due.length > 0) {
168      const isSent = await inject($, next, due, attempt);
169      if (!isCurrent()) return undefined;
170      if (!isSent && attempt < MAX_ATTEMPTS) {
171        watch.undelivered = { head: next.head, decisions: due, attempts: attempt };
172      }
173    }
174  }
175  watch.snapshot = next;
176  show($, watch, statusOf(next));
177  return delayOf(next);
178}
179
180async function poll($: EngineInterface, watch: Watch, generation: number): Promise<void> {
181  const isCurrent = () => !watch.isStopped && watch.generation === generation;
182  if (!isCurrent()) return;
183  let delay: number;
184  try {
185    const read = await readPr($);
186    // A session.end that landed while gh ran has already cleared the status.
187    if (!isCurrent()) return;
188    const applied = await apply($, watch, read, isCurrent);
189    if (applied === undefined) return;
190    delay = applied;
191    watch.error = undefined;
192  } catch (error) {
193    const message = error instanceof Error ? error.message : String(error);
194    if (message !== watch.error) emit($, "poll.error", { error: message }, false);
195    watch.error = message;
196    delay = POLL_MS.error;
197    if (watch.snapshot !== undefined && isCurrent()) {
198      const line = statusOf(watch.snapshot);
199      show($, watch, line === "" ? STALE : `${line} · ${STALE}`);
200    }
201  }
202  if (!isCurrent()) return;
203  watch.timer = $.clock.after(delay, () => void poll($, watch, generation));
204}
205
206async function hasGh($: EngineInterface): Promise<boolean> {
207  try {
208    return (await $.process.run(["gh", "--version"], { timeoutMs: 5000 })).exitCode === 0;
209  } catch {
210    // run() rejects when gh is not on PATH.
211    return false;
212  }
213}
214
215/**
216 * Watches the current branch's pull request with `gh` and shows its CI and
217 * review state at the end of the prompt footer. A newly failed check or any new review is
218 * sent to the model. Every other change is only shown and logged.
219 */
220export function register(on: On): void {
221  const watch: Watch = {
222    snapshot: undefined,
223    undelivered: undefined,
224    error: undefined,
225    timer: undefined,
226    line: undefined,
227    generation: 0,
228    isStopped: true,
229  };
230
231  on("session.start", async ($, e, next) => {
232    const started = await next(e);
233    if (!e.isInteractive) {
234      emit($, "session.start", { active: false, reason: "non-interactive" });
235      return started;
236    }
237    if (!(await hasGh($))) {
238      emit($, "session.start", { active: false, reason: "gh unavailable" });
239      return started;
240    }
241    emit($, "session.start", { active: true });
242    if (watch.isStopped) {
243      watch.isStopped = false;
244      watch.generation += 1;
245      void poll($, watch, watch.generation);
246    }
247    return started;
248  });
249
250  on("ui.render", { component: "PromptHint" }, ($, e, next) => {
251    if (watch.line === undefined) return next(e);
252    const tail = e.props.tail === undefined ? watch.line : `${e.props.tail} · ${watch.line}`;
253    return next({ ...e, props: { ...e.props, tail } });
254  });
255
256  on("session.end", ($, e, next) => {
257    if (!CONTINUING.has(e.reason)) {
258      watch.isStopped = true;
259      watch.timer?.cancel();
260      show($, watch, undefined);
261    }
262    return next(e);
263  });
264}
265
mod/pr.ts 292 lines
1export const VIEW_FIELDS = "number,url,state,headRefOid,reviewDecision,statusCheckRollup,reviews";
2
3export interface CheckNode {
4  __typename?: string;
5  name?: string | null;
6  workflowName?: string | null;
7  context?: string | null;
8  status?: string | null;
9  conclusion?: string | null;
10  state?: string | null;
11  startedAt?: string | null;
12}
13
14export interface ReviewNode {
15  id: string;
16  author?: { login?: string } | null;
17  state: string;
18}
19
20export interface PrView {
21  number: number;
22  url: string;
23  state: string;
24  headRefOid: string;
25  reviewDecision?: string | null;
26  statusCheckRollup?: CheckNode[] | null;
27  reviews?: ReviewNode[] | null;
28}
29
30const isRecord = (value: unknown): value is Record<string, unknown> =>
31  typeof value === "object" && value !== null && !Array.isArray(value);
32
33const textOf = (value: unknown): string | null => (typeof value === "string" ? value : null);
34
35function checkOf(value: unknown): CheckNode | undefined {
36  if (!isRecord(value)) return undefined;
37  return {
38    __typename: textOf(value.__typename) ?? "CheckRun",
39    name: textOf(value.name),
40    workflowName: textOf(value.workflowName),
41    context: textOf(value.context),
42    status: textOf(value.status),
43    conclusion: textOf(value.conclusion),
44    state: textOf(value.state),
45    startedAt: textOf(value.startedAt),
46  };
47}
48
49function reviewOf(value: unknown): ReviewNode | undefined {
50  if (!isRecord(value) || typeof value.id !== "string" || typeof value.state !== "string") {
51    return undefined;
52  }
53  const login = isRecord(value.author) ? textOf(value.author.login) : null;
54  return { id: value.id, state: value.state, author: login === null ? null : { login } };
55}
56
57function listOf<T>(value: unknown, item: (v: unknown) => T | undefined): T[] {
58  if (!Array.isArray(value)) return [];
59  return value.map(item).filter((v): v is T => v !== undefined);
60}
61
62/** Narrows `gh pr view --json` output to a PrView, or undefined when it is not one. */
63export function parseView(stdout: string): PrView | undefined {
64  let value: unknown;
65  try {
66    value = JSON.parse(stdout);
67  } catch {
68    // The caller reports malformed output as an unexpected shape.
69    return undefined;
70  }
71  if (
72    !isRecord(value) ||
73    typeof value.number !== "number" ||
74    typeof value.url !== "string" ||
75    typeof value.state !== "string" ||
76    typeof value.headRefOid !== "string"
77  ) {
78    return undefined;
79  }
80  return {
81    number: value.number,
82    url: value.url,
83    state: value.state,
84    headRefOid: value.headRefOid,
85    reviewDecision: textOf(value.reviewDecision),
86    statusCheckRollup: listOf(value.statusCheckRollup, checkOf),
87    reviews: listOf(value.reviews, reviewOf),
88  };
89}
90
91export type Phase = "none" | "pending" | "passing" | "failing";
92
93export interface Snapshot {
94  pr: number;
95  url: string;
96  state: string;
97  head: string;
98  phase: Phase;
99  total: number;
100  pending: number;
101  failed: string[];
102  reviewDecision: string;
103  reviews: { id: string; author: string; state: string }[];
104}
105
106export type Decision =
107  | { action: "promote"; kind: "ci.failed"; failed: string[] }
108  | { action: "promote"; kind: "review"; author: string; state: string }
109  | { action: "drop"; kind: "ci.passing" | "ci.pending" | "ci.none" }
110  | { action: "drop"; kind: "push"; from: string }
111  | { action: "drop"; kind: "pr.closed" | "pr.merged" };
112
113export type Promoted = Extract<Decision, { action: "promote" }>;
114
115// CANCELLED stays out: a newer push or a concurrency group usually cancelled it.
116const FAILED = new Set(["FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
117const PENDING_STATES = new Set(["PENDING", "EXPECTED"]);
118
119type Outcome = "pending" | "passed" | "failed";
120
121function outcomeOf(node: CheckNode): Outcome {
122  if (node.__typename === "StatusContext") {
123    if (PENDING_STATES.has(node.state ?? "")) return "pending";
124    return FAILED.has(node.state ?? "") ? "failed" : "passed";
125  }
126  if (node.status !== "COMPLETED") return "pending";
127  return FAILED.has(node.conclusion ?? "") ? "failed" : "passed";
128}
129
130const LABEL_MAX = 80;
131
132// Any app with checks access names its checks, and the label reaches the model.
133function flatten(text: string): string {
134  const flat = text
135    .replaceAll(/[\p{Cc}\u2028\u2029]/gu, " ")
136    .replaceAll(/\s+/g, " ")
137    .trim();
138  return flat === "" ? "check" : flat;
139}
140
141// The full label identifies a check. Only what is shown is shortened.
142function labelOf(node: CheckNode): string {
143  if (node.__typename === "StatusContext") return flatten(node.context ?? "status");
144  const name = node.name ?? "check";
145  const workflow = node.workflowName ?? "";
146  return flatten(workflow === "" ? name : `${workflow} / ${name}`);
147}
148
149function shown(label: string): string {
150  return label.length > LABEL_MAX ? `${label.slice(0, LABEL_MAX - 1)}…` : label;
151}
152
153// A rerun lists beside the run it replaces, so keep the latest start per label.
154// One not started yet is queued, hence newest.
155function latest(nodes: CheckNode[]): CheckNode[] {
156  const byLabel = new Map<string, CheckNode>();
157  for (const node of nodes) {
158    const label = `${node.__typename}:${labelOf(node)}`;
159    const held = byLabel.get(label);
160    if (held === undefined || (held.startedAt ?? "\uFFFF") <= (node.startedAt ?? "\uFFFF")) {
161      byLabel.set(label, node);
162    }
163  }
164  return [...byLabel.values()];
165}
166
167export function snapshotOf(view: PrView): Snapshot {
168  const checks = latest(view.statusCheckRollup ?? []);
169  const failed: string[] = [];
170  let pending = 0;
171  for (const node of checks) {
172    const outcome = outcomeOf(node);
173    if (outcome === "failed") failed.push(labelOf(node));
174    else if (outcome === "pending") pending += 1;
175  }
176  failed.sort();
177  let phase: Phase = "passing";
178  if (checks.length === 0) phase = "none";
179  else if (failed.length > 0) phase = "failing";
180  else if (pending > 0) phase = "pending";
181  return {
182    pr: view.number,
183    url: view.url,
184    state: view.state,
185    head: view.headRefOid,
186    phase,
187    total: checks.length,
188    pending,
189    failed,
190    reviewDecision: view.reviewDecision ?? "",
191    reviews: (view.reviews ?? []).map((r) => ({
192      id: r.id,
193      author: r.author?.login ?? "someone",
194      state: r.state,
195    })),
196  };
197}
198
199/**
200 * What changed between two polls of the same PR. A check that newly fails, or
201 * any new review, is promoted. Every other change is dropped.
202 */
203export function decide(prev: Snapshot, next: Snapshot): Decision[] {
204  if (next.state !== "OPEN") {
205    if (prev.state !== "OPEN") return [];
206    return [{ action: "drop", kind: next.state === "MERGED" ? "pr.merged" : "pr.closed" }];
207  }
208  const decisions: Decision[] = [];
209  const isSameHead = prev.head === next.head;
210  if (!isSameHead) decisions.push({ action: "drop", kind: "push", from: prev.head });
211
212  const known = new Set(isSameHead ? prev.failed : []);
213  const failed = next.failed.filter((label) => !known.has(label));
214  if (failed.length > 0) decisions.push({ action: "promote", kind: "ci.failed", failed });
215  else if (next.phase !== prev.phase || !isSameHead) {
216    if (next.phase === "passing") decisions.push({ action: "drop", kind: "ci.passing" });
217    else if (next.phase === "pending") decisions.push({ action: "drop", kind: "ci.pending" });
218    else if (next.phase === "none") decisions.push({ action: "drop", kind: "ci.none" });
219  }
220
221  const seen = new Set(prev.reviews.map((r) => r.id));
222  for (const review of next.reviews) {
223    if (seen.has(review.id)) continue;
224    decisions.push({
225      action: "promote",
226      kind: "review",
227      author: review.author,
228      state: review.state,
229    });
230  }
231  return decisions;
232}
233
234const short = (sha: string) => sha.slice(0, 7);
235
236function listed(labels: string[], max = 3): string {
237  const head = labels.slice(0, max).map(shown).join(", ");
238  return labels.length > max ? `${head} (+${labels.length - max})` : head;
239}
240
241function paragraphOf(snapshot: Snapshot, decision: Promoted): string {
242  const pr = `PR #${snapshot.pr}`;
243  if (decision.kind === "ci.failed") {
244    return [
245      `CI failed on ${pr} at ${short(snapshot.head)}: ${decision.failed.map(shown).join(", ")}.`,
246      `Read the failure with \`gh pr checks ${snapshot.pr}\` and \`gh run view <run> --log-failed\`.`,
247    ].join("\n");
248  }
249  return [
250    `@${decision.author} reviewed ${pr} (${decision.state}): ${snapshot.url}`,
251    `Read the review with \`gh pr view ${snapshot.pr} --comments\`.`,
252  ].join("\n");
253}
254
255export function messageOf(snapshot: Snapshot, promoted: Promoted[]): string {
256  const paragraphs = promoted.map((decision) => paragraphOf(snapshot, decision));
257  paragraphs.push(
258    "The github plugin watches this PR's checks and reviews, so polling them is unnecessary.",
259  );
260  return paragraphs.join("\n\n");
261}
262
263// Nerd Font Octicons, the icons GitHub itself draws for each state.
264export const STALE = "\u{f421} stale";
265
266const ENDED: Partial<Record<string, string>> = {
267  MERGED: "\u{f419} merged",
268  CLOSED: "\u{f4dc} closed",
269};
270
271const CHECKS: Record<Phase, (snapshot: Snapshot) => string | undefined> = {
272  none: () => undefined,
273  pending: (s) => `\u{f444} CI ${s.total - s.pending}/${s.total}`,
274  passing: () => "\u{f49e} CI",
275  failing: (s) => `\u{f52f} ${listed(s.failed)}`,
276};
277
278const REVIEWS: Partial<Record<string, string>> = {
279  APPROVED: "\u{f42e} approved",
280  CHANGES_REQUESTED: "\u{f4d2} changes requested",
281  REVIEW_REQUIRED: "\u{f441} review needed",
282};
283
284// The footer already links the PR, so the state rides after it on that line.
285export function statusOf(snapshot: Snapshot): string {
286  const ended = ENDED[snapshot.state];
287  if (ended !== undefined) return ended;
288  return [CHECKS[snapshot.phase](snapshot), REVIEWS[snapshot.reviewDecision]]
289    .filter((part) => part !== undefined)
290    .join(" · ");
291}
292