GitHub workflow, Actions monitoring, and rulesets management

GitHub workflow, Actions monitoring, and rulesets management for Claude Code.
actions-monitor: Watch a PR's CI and stream state events; invokes the logs agent on failuresattach: Attach an image or video to an issue, PR, comment, or review, through gh --attach or the user-attachments endpoint it wrapscopilot: 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 budgetnotifications: Inbox management (list, filter, mark read/done, unsubscribe)pr-comments: Fetch unresolved review comments from a pull requeststack: Build, publish, and merge native stacked PRs with the gh stack extensiongithub-rulesets-manager: Configure repository rulesets and branch protectionlogs: Extracts relevant lines from failing-job logs (invoked by actions-monitor)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 ghbun test plugins/github
bun scripts/mod-test.ts githubmod/register.ts 265 lines1import 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}
265mod/pr.ts 292 lines1export 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