pr-shepherd 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  Autonomous PR CI monitor and review-comment resolver for Claude Code.
4
4
 
5
+ ## Why pr-shepherd
6
+
7
+ Concrete improvements to an agentic PR-review workflow:
8
+
9
+ - **Faster monitor loops** — one batched GraphQL query per tick (see [docs/graphql.md](docs/graphql.md)) instead of N REST round-trips
10
+ - **Lower context usage per iteration** — classification lives in TypeScript; the agent receives one decision per tick and never sees raw GraphQL payloads or resolved threads
11
+ - **Deterministic output** — `--format=json` and `--format=text` surface equivalent information, so both scripts and agents see the same state
12
+ - **Prompt-cache friendly** — the 4-minute default tick is tuned to Claude's 5-minute prompt-cache TTL (tunable via `watch.interval`)
13
+ - **Reduced GitHub rate-limit exposure** — read results share a 5-minute file cache with atomic writes (see [docs/cache.md](docs/cache.md))
14
+ - **No MCP surface** — skills call the CLI via `npx`; no long-lived MCP server, no extra auth boundary, smaller reasoning surface
15
+ - **Skills over subagents** — skill prompts inject into the main conversation rather than spawning a subagent that reloads CLAUDE.md every turn
16
+ - **Safe to interrupt** — all state lives in the PR on GitHub; the cron loop self-terminates when the PR is merged, closed, or settles after ready-delay
17
+
5
18
  ## Design principles
6
19
 
7
20
  - **Reduced agent context** — logic lives in the CLI, not the prompt
@@ -10,14 +23,32 @@ Autonomous PR CI monitor and review-comment resolver for Claude Code.
10
23
  - **No MCP** — smaller reasoning surface, much faster than the GitHub MCP
11
24
  - **No vendor lock-in** — runs against `gh` + `git`; no hosted service required
12
25
  - **Skills over subagents** — subagents reload all CLAUDE.md context on every turn; skills inject into the main conversation instead, keeping cost low
26
+ - **JSON/text parity** — `--format=json` and `--format=text` carry equivalent information; every field in one has a representation in the other
13
27
 
14
28
  ## Features
15
29
 
16
- - **CI handling** cancels runs on actionable failures, reruns on transient/infra failures, skips non-PR trigger events
17
- - **Comments** — resolves inline threads (including bot and AI reviewer comments), auto-resolves outdated threads before the agent sees them, aggressively hides bot comments, paginates and filters server-side
18
- - **Readiness** converts draft → ready-for-review when CI passes, waits for pending Copilot reviews, settles for a configurable window (default 10 min) before exiting
19
- - **Rebases on conflict** — automatically rebases on the PR base branch when merge conflicts appear
20
- - **Intended as a PR merge blocker** pair with a GitHub Actions required check that verifies all threads are resolved
30
+ pr-shepherd is split into a CLI (deterministic, pure GitHub I/O) and three Claude Code skills that wrap the CLI with model-driven flow and mutation.
31
+
32
+ ### What the CLI does
33
+
34
+ Deterministic commands that fetch, classify, and mutate PR state without invoking the model:
35
+
36
+ - **`check`** — one-shot PR snapshot (merge state, CI results, unresolved comments) via a single batched GraphQL query
37
+ - **`resolve`** — fetch/triage mode auto-resolves outdated threads and returns actionable items; mutate mode batch-resolves threads, minimizes comments, and dismisses reviews by ID, polling `--require-sha` so reviewers see the push before threads close
38
+ - **`iterate`** — classifies current PR state and emits exactly one of eight actions: `cooldown`, `wait`, `rerun_ci`, `mark_ready`, `rebase`, `fix_code`, `cancel`, `escalate` (see [docs/actions.md](docs/actions.md))
39
+ - **`status`** — multi-PR summary table, one lightweight GraphQL query per PR in parallel
40
+
41
+ Cross-cutting machinery: file cache with atomic writes ([docs/cache.md](docs/cache.md)), merge-status derivation ([docs/merge-status.md](docs/merge-status.md)), CI failure classification into `actionable` / `infrastructure` / `timeout` / `flaky` ([docs/checks.md](docs/checks.md)), outdated-thread detection ([docs/comments.md](docs/comments.md)), deprecation-warning-aware RC loader.
42
+
43
+ ### What the skills do
44
+
45
+ Claude Code skills that wrap the CLI with model-driven triage, code edits, and flow control:
46
+
47
+ - **`/pr-shepherd:check`** — calls `check --format=json` and prints a human summary; never declares "ready to merge" unless every gate passes (merge status CLEAN, status READY, Copilot review not in progress)
48
+ - **`/pr-shepherd:monitor`** — creates a `/loop` cron job (4-minute default, 8-hour expiry, 50-turn cap), deduplicates via a `# pr-shepherd-loop:pr=<N>` tag in `CronList`, dispatches on the `[ACTION]` H1 tag each tick, runs rebase scripts and fix instructions in the main conversation
49
+ - **`/pr-shepherd:resolve`** — runs `resolve --fetch`, triages every returned item into Fixed / Actionable / Not-relevant / Outdated / Acknowledge (no silent drops — this is a project invariant), applies edits, cancels stale CI runs, pushes, then calls `resolve` in mutate mode with `--require-sha`
50
+
51
+ See [docs/skills.md](docs/skills.md) for full skill reference.
21
52
 
22
53
  ## Install
23
54
 
@@ -99,7 +130,7 @@ to be installed in the repository first (`npm install pr-shepherd`), so that
99
130
  ```
100
131
 
101
132
  For `monitor` and `resolve` custom commands, do **not** copy the
102
- [`skills/`](skills/) files directly — those contain skill/plugin-specific
133
+ [`plugin/skills/`](plugin/skills/) files directly — those contain skill/plugin-specific
103
134
  frontmatter that is not valid for `.claude/commands/` files. Instead, create
104
135
  `.claude/commands/pr-monitor.md` and/or `.claude/commands/pr-resolve.md`
105
136
  using the same command-file structure as the `pr-check` example above, with
@@ -154,7 +185,7 @@ See [docs/skills.md](docs/skills.md) for full argument reference.
154
185
 
155
186
  ## Workflow
156
187
 
157
- On each 4-minute tick: fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (fix code, rebase, rerun CI, mark ready, or wait). See [docs/flow.md](docs/flow.md) for the full decision tree.
188
+ On each tick (4-minute default, tunable via `watch.interval`): fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (fix code, rebase, rerun CI, mark ready, or wait). See [docs/flow.md](docs/flow.md) for the full decision tree.
158
189
 
159
190
  ## CLI
160
191
 
@@ -167,11 +198,11 @@ pr-shepherd status PR1 [PR2 …] # multi-PR table
167
198
 
168
199
  Common flags (all subcommands):
169
200
 
170
- | Flag | Default | Description |
171
- | --------------------- | ------- | ------------------------------------------------------------------------------ |
172
- | `--format text\|json` | `text` | Output format |
173
- | `--no-cache` | false | Bypass the 5-minute file cache |
174
- | `--cache-ttl N` | `300` | Cache TTL in seconds; `PR_SHEPHERD_CACHE_TTL_SECONDS` env var takes precedence |
201
+ | Flag | Default | Description |
202
+ | --------------------- | ------- | ----------------------------------------------------------------------------------- |
203
+ | `--format text\|json` | `text` | Output format |
204
+ | `--no-cache` | false | Bypass the 5-minute file cache |
205
+ | `--cache-ttl N` | `300` | Cache TTL in seconds; takes precedence over `PR_SHEPHERD_CACHE_TTL_SECONDS` env var |
175
206
 
176
207
  ### pr-shepherd check [PR]
177
208
 
@@ -267,18 +298,20 @@ Flags:
267
298
  | `--no-auto-mark-ready` | false | Skip converting draft → ready-for-review |
268
299
  | `--no-auto-cancel-actionable` | false | Skip cancelling actionable failing runs |
269
300
 
270
- **Text output** (one line per action):
301
+ **Markdown output** (default). The monitor SKILL reads the `[ACTION]` tag from the H1 heading to decide what to do. Every action emits an H1, a bolded base-fields line, a bolded summary line, then an action-specific body. Example for `[WAIT]`:
271
302
 
303
+ ```markdown
304
+ # PR #42 [WAIT]
305
+
306
+ **status** `READY` · **merge** `CLEAN` · **state** `OPEN` · **repo** `owner/repo`
307
+ **summary** 3 passing, 0 skipped, 0 filtered, 0 inProgress · **remainingSeconds** 540 · **copilotReviewInProgress** false · **isDraft** false · **shouldCancel** false
308
+
309
+ WAIT: 3 passing, 0 in-progress — 540s until auto-cancel
272
310
  ```
273
- PR #42 [COOLDOWN] status=UNKNOWN merge=UNKNOWN (cooldown: CI still starting)
274
- PR #42 [WAIT] status=READY merge=CLEAN (540s until cancel)
275
- PR #42 [RERUN_CI] status=FAILING merge=UNSTABLE reran=12345,67890
276
- PR #42 [FIX_CODE] status=UNRESOLVED_COMMENTS merge=BLOCKED threads=2 comments=0 checks=1 cancelled=1
277
- PR #42 [REBASE] status=FAILING merge=BEHIND (branch is behind main)
278
- PR #42 [MARK_READY] status=READY merge=CLEAN markedReady=true
279
- PR #42 [CANCEL] status=READY merge=CLEAN (ready-delay elapsed)
280
- PR #42 [ESCALATE] status=UNRESOLVED_COMMENTS merge=BLOCKED triggers=fix-thrash — Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:monitor
281
- ```
311
+
312
+ See [docs/actions.md](docs/actions.md) for the other seven actions — `cooldown`, `rerun_ci`, `mark_ready`, `cancel`, `rebase`, `fix_code`, `escalate`. `fix_code` is the richest: it emits sections for `## Review threads`, `## Actionable comments`, `## Failing checks`, `## Changes-requested reviews`, `## Noise (minimize only)`, `## Cancelled runs`, `## Rebase`, and `## Instructions`.
313
+
314
+ Both `--format=text` (default Markdown) and `--format=json` carry equivalent information — every field exposed in JSON has a corresponding Markdown representation, and vice versa.
282
315
 
283
316
  **JSON output** (`--format=json`, compact single line):
284
317
 
@@ -322,7 +355,7 @@ Exit code: `0` if every PR is READY, `1` otherwise.
322
355
 
323
356
  ## Configuration
324
357
 
325
- Create a `.pr-shepherdrc.yml` in your project root (or any parent directory) to override defaults:
358
+ Create a `.pr-shepherdrc.yml` in your project root (or any parent directory) to override defaults. The loader walks up from `cwd` to `$HOME` (if `$HOME` is on that path) or the filesystem root; the first match wins.
326
359
 
327
360
  ```yaml
328
361
  iterate:
@@ -336,12 +369,39 @@ actions:
336
369
  autoRebase: false # disable for repos that enforce merge commits
337
370
  ```
338
371
 
339
- See [docs/configuration.md](docs/configuration.md) for all options.
372
+ All supported keys:
373
+
374
+ | Key | Default | Purpose |
375
+ | ------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------- |
376
+ | `cache.ttlSeconds` | `300` | File-cache TTL for read operations |
377
+ | `iterate.cooldownSeconds` | `30` | Wait after a push before reading CI |
378
+ | `iterate.fixAttemptsPerThread` | `3` | Max fix attempts per unresolved thread before `escalate` |
379
+ | `watch.interval` | `"4m"` | Monitor tick interval (tuned to Claude's 5-min prompt-cache TTL) |
380
+ | `watch.readyDelayMinutes` | `10` | Settle window after READY before the monitor loop cancels |
381
+ | `watch.expiresHours` | `8` | Max lifetime of a monitor cron job |
382
+ | `watch.maxTurns` | `50` | Max monitor ticks per session |
383
+ | `resolve.concurrency` | `4` | Parallel fanout for per-thread GraphQL fetches |
384
+ | `resolve.shaPoll.intervalMs` | `2000` | Poll interval when waiting for `--require-sha` to land on GitHub |
385
+ | `resolve.shaPoll.maxAttempts` | `10` | Max `--require-sha` polls before giving up |
386
+ | `resolve.fetchReviewSummaries` | `true` | Surface `COMMENTED` review summaries in `resolve --fetch` output |
387
+ | `checks.ciTriggerEvents` | `["pull_request", "pull_request_target"]` | Workflow `on:` events treated as PR CI (add `merge_group` for merge-queue repos) |
388
+ | `checks.timeoutPatterns` | see [`src/config.json`](src/config.json) | Log patterns that classify a failure as `timeout` |
389
+ | `checks.infraPatterns` | see [`src/config.json`](src/config.json) | Log patterns that classify a failure as `infrastructure` |
390
+ | `checks.logMaxLines` | `50` | Max log lines kept per failing check |
391
+ | `checks.logMaxChars` | `3000` | Max log characters kept per failing check |
392
+ | `mergeStatus.blockingReviewerLogins` | `["copilot"]` | Reviewer logins whose pending review blocks `mark_ready` |
393
+ | `actions.autoResolveOutdated` | `true` | Auto-resolve threads that point to code no longer in the PR diff |
394
+ | `actions.autoRebase` | `true` | Emit `rebase` for flaky failures when the branch is behind base |
395
+ | `actions.autoMarkReady` | `true` | Emit `mark_ready` when a draft PR's CI goes clean |
396
+
397
+ Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`), `PR_SHEPHERD_CACHE_DIR` (override cache base dir), `PR_SHEPHERD_CACHE_TTL_SECONDS` (override cache TTL; `--cache-ttl` takes precedence over this env var, which in turn takes precedence over the RC/config value).
398
+
399
+ See [docs/configuration.md](docs/configuration.md) for full semantics and deprecated-key migration.
340
400
 
341
401
  ## Requirements
342
402
 
343
403
  - Node.js ≥ 22.0.0
344
- - `gh` CLI authenticated (`gh auth login`); `repo` scope is required for private repositories (public repositories may not need it)
404
+ - A GitHub token: set `GH_TOKEN` or `GITHUB_TOKEN`, **or** install and authenticate the `gh` CLI (`gh auth login`) — pr-shepherd uses `gh auth token` as a fallback. The `repo` scope is required for private repositories.
345
405
  - `git`
346
406
 
347
407
  ## Docs
@@ -8,10 +8,8 @@
8
8
  * Shepherd computes and returns triage results, including `failureKind`, for
9
9
  * downstream callers or slash-command logic to consume.
10
10
  */
11
- import { execFile as execFileCb } from "node:child_process";
12
- import { promisify } from "node:util";
11
+ import { rest, restText } from "../github/http.mjs";
13
12
  import { loadConfig } from "../config/load.mjs";
14
- const execFile = promisify(execFileCb);
15
13
  const config = loadConfig();
16
14
  const TIMEOUT_PATTERNS = config.checks.timeoutPatterns.map((p) => new RegExp(p, "i"));
17
15
  const INFRA_PATTERNS = config.checks.infraPatterns.map((p) => new RegExp(p, "i"));
@@ -23,17 +21,17 @@ const INFRA_PATTERNS = config.checks.infraPatterns.map((p) => new RegExp(p, "i")
23
21
  *
24
22
  * Fetching logs is skipped for checks that have no `runId` (e.g. StatusContext nodes).
25
23
  */
26
- export function triageFailingChecks(failingChecks) {
27
- return Promise.all(failingChecks.map((c) => triageCheck(c)));
24
+ export function triageFailingChecks(failingChecks, repo) {
25
+ return Promise.all(failingChecks.map((c) => triageCheck(c, repo)));
28
26
  }
29
27
  // ---------------------------------------------------------------------------
30
28
  // Internal
31
29
  // ---------------------------------------------------------------------------
32
- async function triageCheck(check) {
30
+ async function triageCheck(check, repo) {
33
31
  if (check.runId === null) {
34
32
  return { ...check, failureKind: "actionable" };
35
33
  }
36
- const logExcerpt = await fetchFailedLogs(check.runId);
34
+ const logExcerpt = await fetchFailedLogs(check.runId, repo);
37
35
  const failureKind = classifyLogs(check, logExcerpt);
38
36
  return {
39
37
  ...check,
@@ -41,37 +39,54 @@ async function triageCheck(check) {
41
39
  logExcerpt: logExcerpt.slice(-config.checks.logMaxChars) || undefined,
42
40
  };
43
41
  }
44
- async function fetchFailedLogs(runId) {
42
+ async function fetchFailedLogs(runId, repo) {
45
43
  try {
46
- const { stdout } = await execFile("gh", ["run", "view", runId, "--log-failed"], {
47
- maxBuffer: config.execution.triageLogBufferMb * 1024 * 1024,
48
- });
49
- // Strip ANSI escape codes.
50
- // eslint-disable-next-line no-control-regex
44
+ const { owner, name } = repo;
45
+ const perPage = 100;
46
+ const allJobs = [];
47
+ for (let page = 1;; page++) {
48
+ const jobsData = await rest("GET", `/repos/${owner}/${name}/actions/runs/${runId}/jobs?filter=latest&per_page=${perPage}&page=${page}`);
49
+ allJobs.push(...jobsData.jobs);
50
+ if (jobsData.jobs.length < perPage)
51
+ break;
52
+ }
53
+ const failedJobs = allJobs.filter((j) => ["failure", "timed_out", "cancelled"].includes(j.conclusion ?? ""));
54
+ if (failedJobs.length === 0)
55
+ return "";
56
+ const logParts = await Promise.all(failedJobs.map(async (job) => {
57
+ try {
58
+ // Job-level endpoint (jobs/{id}/logs) redirects to plain text, unlike
59
+ // run-level (runs/{id}/logs) which returns a ZIP archive.
60
+ const logs = await restText(`/repos/${owner}/${name}/actions/jobs/${job.id}/logs`);
61
+ return logs.trim() ? `===== job: ${job.name} =====\n${logs}` : "";
62
+ }
63
+ catch {
64
+ return "";
65
+ }
66
+ }));
67
+ const combined = logParts.filter(Boolean).join("\n");
51
68
  const ansiEscapes = /\u001B\[[0-9;]*m/g;
52
- return stdout.replace(ansiEscapes, "").split("\n").slice(-config.checks.logMaxLines).join("\n");
69
+ return combined
70
+ .replace(ansiEscapes, "")
71
+ .split("\n")
72
+ .slice(-config.checks.logMaxLines)
73
+ .join("\n");
53
74
  }
54
75
  catch {
55
76
  return "";
56
77
  }
57
78
  }
58
79
  function classifyLogs(check, logs) {
59
- // Timed out — check conclusion first, then logs.
60
80
  if (check.conclusion === "TIMED_OUT")
61
81
  return "timeout";
62
82
  if (TIMEOUT_PATTERNS.some((re) => re.test(logs)))
63
83
  return "timeout";
64
- // Infrastructure error — typically CANCELLED with infra markers in logs.
65
84
  if (check.conclusion === "CANCELLED" && INFRA_PATTERNS.some((re) => re.test(logs))) {
66
85
  return "infrastructure";
67
86
  }
68
- // No logs at all — treat as infrastructure.
69
87
  if (!logs.trim())
70
88
  return "infrastructure";
71
- // Heuristic: if the failure is in a file the PR likely didn't touch
72
- // and the message contains "flaky" or timing language, call it flaky.
73
89
  if (/flaky|timing|race condition|retry/i.test(logs))
74
90
  return "flaky";
75
- // Default: assume actionable.
76
91
  return "actionable";
77
92
  }
package/bin/cli.mjs CHANGED
@@ -6,12 +6,12 @@
6
6
  * pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
7
7
  * [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
8
8
  * [--last-push-time N]
9
- * pr-shepherd iterate [PR] [--cooldown-seconds N] [--ready-delay Nm] [--last-push-time N]
9
+ * pr-shepherd iterate [PR] [--format text|json] [--cooldown-seconds N] [--ready-delay Nm] [--last-push-time N]
10
10
  * pr-shepherd status PR1 [PR2 …]
11
11
  */
12
12
  import { runCheck } from "./commands/check.mjs";
13
13
  import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
14
- import { runIterate } from "./commands/iterate.mjs";
14
+ import { runIterate, renderResolveCommand } from "./commands/iterate.mjs";
15
15
  import { runStatus, formatStatusTable } from "./commands/status.mjs";
16
16
  import { getRepoInfo } from "./github/client.mjs";
17
17
  import { formatJson } from "./reporters/json.mjs";
@@ -163,9 +163,16 @@ function formatFetchResult(result) {
163
163
  lines.push(` - reviewId=${r.id} (@${r.author})`);
164
164
  }
165
165
  }
166
+ if (result.reviewSummaries.length > 0) {
167
+ lines.push(`\nReview summaries (${result.reviewSummaries.length}):`);
168
+ for (const r of result.reviewSummaries) {
169
+ lines.push(` - reviewId=${r.id} (@${r.author}): ${r.body.split("\n")[0].slice(0, 100)}`);
170
+ }
171
+ }
166
172
  const total = result.actionableThreads.length +
167
173
  result.actionableComments.length +
168
- result.changesRequestedReviews.length;
174
+ result.changesRequestedReviews.length +
175
+ result.reviewSummaries.length;
169
176
  lines.push(`\nSummary: ${total === 0 ? "0 actionable — all threads resolved/minimized" : `${total} actionable item(s)`}`);
170
177
  return `${lines.join("\n")}\n`;
171
178
  }
@@ -181,24 +188,99 @@ function formatMutateResult(result) {
181
188
  lines.push(`Errors:\n ${result.errors.join("\n ")}`);
182
189
  return `${lines.join("\n")}\n`;
183
190
  }
191
+ /**
192
+ * Format an IterateResult as human-readable Markdown.
193
+ *
194
+ * Load-bearing conventions the monitor SKILL relies on:
195
+ * 1. The H1 heading on line 1 contains `[<ACTION>]` — the SKILL greps this tag.
196
+ * 2. The `resolve` bullet under `## Rebase` wraps the command in backticks —
197
+ * the SKILL extracts the backticked content for execution.
198
+ * 3. The shell script under `[REBASE]` is inside a ```bash fenced block.
199
+ * 4. `## Instructions` items are numbered `1.`, `2.`, … and executed in order.
200
+ */
184
201
  function formatIterateResult(result) {
185
- const base = `PR #${result.pr} [${result.action.toUpperCase()}] status=${result.status} merge=${result.mergeStateStatus}`;
202
+ const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
203
+ const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\` · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
204
+ const summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress · **remainingSeconds** ${result.remainingSeconds} · **copilotReviewInProgress** ${result.copilotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}`;
205
+ const header = [heading, "", baseLine, summaryLine].join("\n");
186
206
  switch (result.action) {
187
207
  case "cooldown":
188
- return `${base} (cooldown: CI still starting)`;
189
208
  case "wait":
190
- return `${base} (${result.remainingSeconds}s until cancel)`;
191
209
  case "cancel":
192
- return `${base} (ready-delay elapsed)`;
193
- case "fix_code":
194
- return `${base} threads=${result.fix.threads.length} comments=${result.fix.comments.length} checks=${result.fix.checks.length} cancelled=${result.cancelled.length}`;
195
210
  case "rerun_ci":
196
- return `${base} reran=${result.reran.join(",")}`;
197
- case "rebase":
198
- return `${base} (branch is behind main)`;
199
211
  case "mark_ready":
200
- return `${base} markedReady=${result.markedReady}`;
212
+ return [header, "", result.log].join("\n");
213
+ case "rebase":
214
+ return [
215
+ header,
216
+ "",
217
+ result.rebase.reason,
218
+ "",
219
+ "```bash",
220
+ result.rebase.shellScript,
221
+ "```",
222
+ ].join("\n");
201
223
  case "escalate":
202
- return `${base} triggers=${result.escalate.triggers.join(",")} — ${result.escalate.suggestion}`;
224
+ return [header, "", result.escalate.humanMessage].join("\n");
225
+ case "fix_code":
226
+ return formatFixCodeResult(header, result);
203
227
  }
204
228
  }
229
+ function formatFixCodeResult(header, result) {
230
+ const sections = [header];
231
+ if (result.fix.threads.length > 0) {
232
+ sections.push("## Review threads");
233
+ for (const t of result.fix.threads) {
234
+ const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
235
+ sections.push(`### \`${t.id}\` — ${loc} (@${t.author})`);
236
+ sections.push(blockquote(t.body));
237
+ }
238
+ }
239
+ if (result.fix.actionableComments.length > 0) {
240
+ sections.push("## Actionable comments");
241
+ for (const c of result.fix.actionableComments) {
242
+ sections.push(`### \`${c.id}\` (@${c.author})`);
243
+ sections.push(blockquote(c.body));
244
+ }
245
+ }
246
+ if (result.fix.checks.length > 0) {
247
+ sections.push("## Failing checks");
248
+ const bullets = result.fix.checks.map((ch) => {
249
+ const kind = ch.failureKind ?? "actionable";
250
+ if (ch.runId)
251
+ return `- \`${ch.runId}\` — \`${ch.name}\` (${kind})`;
252
+ if (ch.detailsUrl)
253
+ return `- external \`${ch.detailsUrl}\` — \`${ch.name}\` (${kind})`;
254
+ return `- (no runId) — \`${ch.name}\` (${kind})`;
255
+ });
256
+ sections.push(bullets.join("\n"));
257
+ }
258
+ if (result.fix.changesRequestedReviews.length > 0) {
259
+ sections.push("## Changes-requested reviews");
260
+ sections.push(result.fix.changesRequestedReviews.map((r) => `- \`${r.id}\` (@${r.author})`).join("\n"));
261
+ }
262
+ if (result.fix.noiseCommentIds.length > 0) {
263
+ sections.push("## Noise (minimize only)");
264
+ sections.push(result.fix.noiseCommentIds.map((id) => `\`${id}\``).join(", "));
265
+ }
266
+ if (result.cancelled.length > 0) {
267
+ sections.push("## Cancelled runs");
268
+ sections.push(result.cancelled.map((id) => `\`${id}\``).join(", "));
269
+ }
270
+ sections.push("## Rebase");
271
+ sections.push([
272
+ `- base: \`${result.baseBranch}\``,
273
+ `- resolve: \`${renderResolveCommand(result.fix.resolveCommand)}\``,
274
+ ].join("\n"));
275
+ if (result.fix.instructions.length > 0) {
276
+ sections.push("## Instructions");
277
+ sections.push(result.fix.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
278
+ }
279
+ return sections.join("\n\n");
280
+ }
281
+ function blockquote(body) {
282
+ return body
283
+ .split("\n")
284
+ .map((line) => (line === "" ? ">" : `> ${line}`))
285
+ .join("\n");
286
+ }
@@ -6,10 +6,11 @@
6
6
  *
7
7
  * Exit codes:
8
8
  * 0 READY — all checks passed, no unresolved threads, CLEAN merge status.
9
- * 1 FAILING — one or more CI checks failed.
9
+ * 1 FAILING — CI has red checks, or merge has conflicts.
10
+ * 1 PENDING — CI passing but merge blocked (BLOCKED, UNSTABLE, or BEHIND).
11
+ * 1 UNKNOWN — merge state unresolvable.
10
12
  * 2 IN_PROGRESS — CI checks still running.
11
13
  * 3 UNRESOLVED_COMMENTS — CI ok but actionable threads remain.
12
- * 1 (also) BLOCKED/CONFLICTS/UNKNOWN merge status.
13
14
  */
14
15
  import { fetchPrBatch } from "../github/batch.mjs";
15
16
  import { getRepoInfo, getCurrentPrNumber, getMergeableState } from "../github/client.mjs";
@@ -60,7 +61,7 @@ export async function runCheck(opts) {
60
61
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
61
62
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
62
63
  // Triage failures (fetch logs) — skipped when caller will short-circuit before needing failureKind.
63
- const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing) : failing;
64
+ const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
64
65
  // Resolve threads and comments.
65
66
  const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
66
67
  const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
@@ -88,8 +89,10 @@ export async function runCheck(opts) {
88
89
  const status = computeStatus(verdict, actionableThreads.length, actionableComments.length, mergeStatus, batchData.changesRequestedReviews.length);
89
90
  return {
90
91
  pr: prNumber,
92
+ nodeId: batchData.nodeId,
91
93
  repo: `${repo.owner}/${repo.name}`,
92
94
  status,
95
+ baseBranch: batchData.baseRefName,
93
96
  mergeStatus,
94
97
  checks: {
95
98
  passing,
@@ -121,6 +124,7 @@ function computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStat
121
124
  return "FAILING";
122
125
  // Check CI state before merge-blocking states: BLOCKED/UNSTABLE/BEHIND are
123
126
  // often caused by CI not having passed yet, so they shouldn't mask IN_PROGRESS.
127
+ // These merge-blocking states become PENDING (not FAILING) once CI is resolved.
124
128
  if (verdict.anyFailing)
125
129
  return "FAILING";
126
130
  if (verdict.anyInProgress)
@@ -139,7 +143,7 @@ function computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStat
139
143
  if (mergeStatus.status === "BLOCKED" ||
140
144
  mergeStatus.status === "UNSTABLE" ||
141
145
  mergeStatus.status === "BEHIND")
142
- return "FAILING";
146
+ return "PENDING";
143
147
  if (mergeStatus.status === "UNKNOWN")
144
148
  return "UNKNOWN";
145
149
  if (changesRequestedReviews > 0)