pr-shepherd 0.5.2 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +85 -25
- package/bin/checks/triage.mjs +35 -20
- package/bin/cli.mjs +96 -14
- package/bin/commands/check.mjs +26 -9
- package/bin/commands/iterate.mjs +365 -38
- package/bin/commands/resolve.mjs +4 -1
- package/bin/config/load.mjs +5 -0
- package/bin/config.json +2 -5
- package/bin/github/batch.mjs +36 -8
- package/bin/github/client.mjs +39 -110
- package/bin/github/gql/batch-pr.gql +23 -2
- package/bin/github/gql/mark-pr-ready.gql +7 -0
- package/bin/github/gql/pr-number-by-branch.gql +9 -0
- package/bin/github/http.mjs +145 -0
- package/bin/github/queries.mjs +4 -0
- package/package.json +1 -1
- package/plugin/skills/monitor/SKILL.md +24 -67
- package/plugin/skills/resolve/SKILL.md +27 -12
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
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/bin/checks/triage.mjs
CHANGED
|
@@ -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 {
|
|
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 {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/bin/commands/check.mjs
CHANGED
|
@@ -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 —
|
|
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);
|
|
@@ -85,11 +86,13 @@ export async function runCheck(opts) {
|
|
|
85
86
|
!verdict.anyInProgress &&
|
|
86
87
|
verdict.filteredNames.length > 0;
|
|
87
88
|
// Compute overall status.
|
|
88
|
-
const status = computeStatus(verdict, actionableThreads.length, actionableComments.length, mergeStatus
|
|
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,
|
|
@@ -117,24 +120,38 @@ export async function runCheck(opts) {
|
|
|
117
120
|
// ---------------------------------------------------------------------------
|
|
118
121
|
function computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedReviews) {
|
|
119
122
|
// Merge conflicts are always terminal regardless of CI state.
|
|
120
|
-
if (mergeStatus === "CONFLICTS")
|
|
123
|
+
if (mergeStatus.status === "CONFLICTS")
|
|
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)
|
|
127
131
|
return "IN_PROGRESS";
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
132
|
+
// BLOCKED solely because a human reviewer hasn't approved yet — shepherd is done, hand off.
|
|
133
|
+
// copilotReviewInProgress means a bot still owes a review, which is not this case.
|
|
134
|
+
if (verdict.allPassed &&
|
|
135
|
+
unresolvedThreads === 0 &&
|
|
136
|
+
unresolvedComments === 0 &&
|
|
137
|
+
changesRequestedReviews === 0 &&
|
|
138
|
+
mergeStatus.status === "BLOCKED" &&
|
|
139
|
+
!mergeStatus.copilotReviewInProgress &&
|
|
140
|
+
mergeStatus.reviewDecision === "REVIEW_REQUIRED") {
|
|
141
|
+
return "READY";
|
|
142
|
+
}
|
|
143
|
+
if (mergeStatus.status === "BLOCKED" ||
|
|
144
|
+
mergeStatus.status === "UNSTABLE" ||
|
|
145
|
+
mergeStatus.status === "BEHIND")
|
|
146
|
+
return "PENDING";
|
|
147
|
+
if (mergeStatus.status === "UNKNOWN")
|
|
131
148
|
return "UNKNOWN";
|
|
132
149
|
if (changesRequestedReviews > 0)
|
|
133
150
|
return "UNRESOLVED_COMMENTS";
|
|
134
151
|
if (unresolvedThreads > 0 || unresolvedComments > 0)
|
|
135
152
|
return "UNRESOLVED_COMMENTS";
|
|
136
153
|
// DRAFT is treated the same as CLEAN for readiness — marking the PR ready resolves it.
|
|
137
|
-
if ((mergeStatus === "CLEAN" || mergeStatus === "DRAFT") && verdict.allPassed)
|
|
154
|
+
if ((mergeStatus.status === "CLEAN" || mergeStatus.status === "DRAFT") && verdict.allPassed)
|
|
138
155
|
return "READY";
|
|
139
156
|
return "UNKNOWN";
|
|
140
157
|
}
|