pr-shepherd 0.6.0 → 0.7.1
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 +86 -25
- package/bin/checks/triage.mjs +35 -20
- package/bin/cli.mjs +109 -15
- package/bin/commands/check.mjs +8 -4
- package/bin/commands/iterate.mjs +365 -38
- package/bin/commands/resolve.mjs +3 -0
- 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 +23 -74
- 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,11 +185,12 @@ 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
|
|
|
161
192
|
```sh
|
|
193
|
+
pr-shepherd -v|--version # print installed version
|
|
162
194
|
pr-shepherd check [PR] # read-only PR status snapshot
|
|
163
195
|
pr-shepherd resolve [PR] [--fetch | --resolve-thread-ids …]
|
|
164
196
|
pr-shepherd iterate [PR] [--cooldown-seconds N] [--ready-delay Nm] [--last-push-time N]
|
|
@@ -167,11 +199,11 @@ pr-shepherd status PR1 [PR2 …] # multi-PR table
|
|
|
167
199
|
|
|
168
200
|
Common flags (all subcommands):
|
|
169
201
|
|
|
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
|
|
202
|
+
| Flag | Default | Description |
|
|
203
|
+
| --------------------- | ------- | ----------------------------------------------------------------------------------- |
|
|
204
|
+
| `--format text\|json` | `text` | Output format |
|
|
205
|
+
| `--no-cache` | false | Bypass the 5-minute file cache |
|
|
206
|
+
| `--cache-ttl N` | `300` | Cache TTL in seconds; takes precedence over `PR_SHEPHERD_CACHE_TTL_SECONDS` env var |
|
|
175
207
|
|
|
176
208
|
### pr-shepherd check [PR]
|
|
177
209
|
|
|
@@ -267,18 +299,20 @@ Flags:
|
|
|
267
299
|
| `--no-auto-mark-ready` | false | Skip converting draft → ready-for-review |
|
|
268
300
|
| `--no-auto-cancel-actionable` | false | Skip cancelling actionable failing runs |
|
|
269
301
|
|
|
270
|
-
**
|
|
302
|
+
**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
303
|
|
|
304
|
+
```markdown
|
|
305
|
+
# PR #42 [WAIT]
|
|
306
|
+
|
|
307
|
+
**status** `READY` · **merge** `CLEAN` · **state** `OPEN` · **repo** `owner/repo`
|
|
308
|
+
**summary** 3 passing, 0 skipped, 0 filtered, 0 inProgress · **remainingSeconds** 540 · **copilotReviewInProgress** false · **isDraft** false · **shouldCancel** false
|
|
309
|
+
|
|
310
|
+
WAIT: 3 passing, 0 in-progress — 540s until auto-cancel
|
|
272
311
|
```
|
|
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
|
-
```
|
|
312
|
+
|
|
313
|
+
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`.
|
|
314
|
+
|
|
315
|
+
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
316
|
|
|
283
317
|
**JSON output** (`--format=json`, compact single line):
|
|
284
318
|
|
|
@@ -322,7 +356,7 @@ Exit code: `0` if every PR is READY, `1` otherwise.
|
|
|
322
356
|
|
|
323
357
|
## Configuration
|
|
324
358
|
|
|
325
|
-
Create a `.pr-shepherdrc.yml` in your project root (or any parent directory) to override defaults
|
|
359
|
+
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
360
|
|
|
327
361
|
```yaml
|
|
328
362
|
iterate:
|
|
@@ -336,12 +370,39 @@ actions:
|
|
|
336
370
|
autoRebase: false # disable for repos that enforce merge commits
|
|
337
371
|
```
|
|
338
372
|
|
|
339
|
-
|
|
373
|
+
All supported keys:
|
|
374
|
+
|
|
375
|
+
| Key | Default | Purpose |
|
|
376
|
+
| ------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------- |
|
|
377
|
+
| `cache.ttlSeconds` | `300` | File-cache TTL for read operations |
|
|
378
|
+
| `iterate.cooldownSeconds` | `30` | Wait after a push before reading CI |
|
|
379
|
+
| `iterate.fixAttemptsPerThread` | `3` | Max fix attempts per unresolved thread before `escalate` |
|
|
380
|
+
| `watch.interval` | `"4m"` | Monitor tick interval (tuned to Claude's 5-min prompt-cache TTL) |
|
|
381
|
+
| `watch.readyDelayMinutes` | `10` | Settle window after READY before the monitor loop cancels |
|
|
382
|
+
| `watch.expiresHours` | `8` | Max lifetime of a monitor cron job |
|
|
383
|
+
| `watch.maxTurns` | `50` | Max monitor ticks per session |
|
|
384
|
+
| `resolve.concurrency` | `4` | Parallel fanout for per-thread GraphQL fetches |
|
|
385
|
+
| `resolve.shaPoll.intervalMs` | `2000` | Poll interval when waiting for `--require-sha` to land on GitHub |
|
|
386
|
+
| `resolve.shaPoll.maxAttempts` | `10` | Max `--require-sha` polls before giving up |
|
|
387
|
+
| `resolve.fetchReviewSummaries` | `true` | Surface `COMMENTED` review summaries in `resolve --fetch` output |
|
|
388
|
+
| `checks.ciTriggerEvents` | `["pull_request", "pull_request_target"]` | Workflow `on:` events treated as PR CI (add `merge_group` for merge-queue repos) |
|
|
389
|
+
| `checks.timeoutPatterns` | see [`src/config.json`](src/config.json) | Log patterns that classify a failure as `timeout` |
|
|
390
|
+
| `checks.infraPatterns` | see [`src/config.json`](src/config.json) | Log patterns that classify a failure as `infrastructure` |
|
|
391
|
+
| `checks.logMaxLines` | `50` | Max log lines kept per failing check |
|
|
392
|
+
| `checks.logMaxChars` | `3000` | Max log characters kept per failing check |
|
|
393
|
+
| `mergeStatus.blockingReviewerLogins` | `["copilot"]` | Reviewer logins whose pending review blocks `mark_ready` |
|
|
394
|
+
| `actions.autoResolveOutdated` | `true` | Auto-resolve threads that point to code no longer in the PR diff |
|
|
395
|
+
| `actions.autoRebase` | `true` | Emit `rebase` for flaky failures when the branch is behind base |
|
|
396
|
+
| `actions.autoMarkReady` | `true` | Emit `mark_ready` when a draft PR's CI goes clean |
|
|
397
|
+
|
|
398
|
+
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).
|
|
399
|
+
|
|
400
|
+
See [docs/configuration.md](docs/configuration.md) for full semantics and deprecated-key migration.
|
|
340
401
|
|
|
341
402
|
## Requirements
|
|
342
403
|
|
|
343
404
|
- Node.js ≥ 22.0.0
|
|
344
|
-
- `gh` CLI
|
|
405
|
+
- 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
406
|
- `git`
|
|
346
407
|
|
|
347
408
|
## 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
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
* CLI argument parsing and subcommand dispatch for pr-shepherd.
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
+
* pr-shepherd --version
|
|
5
6
|
* pr-shepherd check [PR] [--format text|json] [--no-cache] [--cache-ttl N]
|
|
6
7
|
* pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
|
|
7
8
|
* [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
|
|
8
9
|
* [--last-push-time N]
|
|
9
|
-
* pr-shepherd iterate [PR] [--cooldown-seconds N] [--ready-delay Nm] [--last-push-time N]
|
|
10
|
+
* pr-shepherd iterate [PR] [--format text|json] [--cooldown-seconds N] [--ready-delay Nm] [--last-push-time N]
|
|
10
11
|
* pr-shepherd status PR1 [PR2 …]
|
|
11
12
|
*/
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
12
14
|
import { runCheck } from "./commands/check.mjs";
|
|
13
15
|
import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
|
|
14
|
-
import { runIterate } from "./commands/iterate.mjs";
|
|
16
|
+
import { runIterate, renderResolveCommand } from "./commands/iterate.mjs";
|
|
15
17
|
import { runStatus, formatStatusTable } from "./commands/status.mjs";
|
|
16
18
|
import { getRepoInfo } from "./github/client.mjs";
|
|
17
19
|
import { formatJson } from "./reporters/json.mjs";
|
|
@@ -24,6 +26,10 @@ import { parseCommonArgs, getFlag, hasFlag, parseList, parseStatusPrNumbers, par
|
|
|
24
26
|
export async function main(argv) {
|
|
25
27
|
const args = argv.slice(2); // strip node + script path
|
|
26
28
|
const subcommand = args[0];
|
|
29
|
+
if (subcommand === "--version" || subcommand === "-v") {
|
|
30
|
+
process.stdout.write(`${readVersion()}\n`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
27
33
|
switch (subcommand) {
|
|
28
34
|
case "check":
|
|
29
35
|
await handleCheck(args.slice(1));
|
|
@@ -39,11 +45,17 @@ export async function main(argv) {
|
|
|
39
45
|
break;
|
|
40
46
|
default:
|
|
41
47
|
process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
|
|
42
|
-
process.stderr.write("Usage: pr-shepherd <check|resolve|iterate|status> [options]\n"
|
|
48
|
+
process.stderr.write("Usage: pr-shepherd <check|resolve|iterate|status> [options]\n" +
|
|
49
|
+
" pr-shepherd --version | -v\n");
|
|
43
50
|
process.exitCode = 1;
|
|
44
51
|
return;
|
|
45
52
|
}
|
|
46
53
|
}
|
|
54
|
+
function readVersion() {
|
|
55
|
+
const pkgUrl = new URL("../package.json", import.meta.url);
|
|
56
|
+
const pkg = JSON.parse(readFileSync(pkgUrl, "utf8"));
|
|
57
|
+
return pkg.version;
|
|
58
|
+
}
|
|
47
59
|
// ---------------------------------------------------------------------------
|
|
48
60
|
// Subcommand handlers
|
|
49
61
|
// ---------------------------------------------------------------------------
|
|
@@ -163,9 +175,16 @@ function formatFetchResult(result) {
|
|
|
163
175
|
lines.push(` - reviewId=${r.id} (@${r.author})`);
|
|
164
176
|
}
|
|
165
177
|
}
|
|
178
|
+
if (result.reviewSummaries.length > 0) {
|
|
179
|
+
lines.push(`\nReview summaries (${result.reviewSummaries.length}):`);
|
|
180
|
+
for (const r of result.reviewSummaries) {
|
|
181
|
+
lines.push(` - reviewId=${r.id} (@${r.author}): ${r.body.split("\n")[0].slice(0, 100)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
166
184
|
const total = result.actionableThreads.length +
|
|
167
185
|
result.actionableComments.length +
|
|
168
|
-
result.changesRequestedReviews.length
|
|
186
|
+
result.changesRequestedReviews.length +
|
|
187
|
+
result.reviewSummaries.length;
|
|
169
188
|
lines.push(`\nSummary: ${total === 0 ? "0 actionable — all threads resolved/minimized" : `${total} actionable item(s)`}`);
|
|
170
189
|
return `${lines.join("\n")}\n`;
|
|
171
190
|
}
|
|
@@ -181,24 +200,99 @@ function formatMutateResult(result) {
|
|
|
181
200
|
lines.push(`Errors:\n ${result.errors.join("\n ")}`);
|
|
182
201
|
return `${lines.join("\n")}\n`;
|
|
183
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Format an IterateResult as human-readable Markdown.
|
|
205
|
+
*
|
|
206
|
+
* Load-bearing conventions the monitor SKILL relies on:
|
|
207
|
+
* 1. The H1 heading on line 1 contains `[<ACTION>]` — the SKILL greps this tag.
|
|
208
|
+
* 2. The `resolve` bullet under `## Rebase` wraps the command in backticks —
|
|
209
|
+
* the SKILL extracts the backticked content for execution.
|
|
210
|
+
* 3. The shell script under `[REBASE]` is inside a ```bash fenced block.
|
|
211
|
+
* 4. `## Instructions` items are numbered `1.`, `2.`, … and executed in order.
|
|
212
|
+
*/
|
|
184
213
|
function formatIterateResult(result) {
|
|
185
|
-
const
|
|
214
|
+
const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
|
|
215
|
+
const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\` · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
|
|
216
|
+
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}`;
|
|
217
|
+
const header = [heading, "", baseLine, summaryLine].join("\n");
|
|
186
218
|
switch (result.action) {
|
|
187
219
|
case "cooldown":
|
|
188
|
-
return `${base} (cooldown: CI still starting)`;
|
|
189
220
|
case "wait":
|
|
190
|
-
return `${base} (${result.remainingSeconds}s until cancel)`;
|
|
191
221
|
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
222
|
case "rerun_ci":
|
|
196
|
-
return `${base} reran=${result.reran.join(",")}`;
|
|
197
|
-
case "rebase":
|
|
198
|
-
return `${base} (branch is behind main)`;
|
|
199
223
|
case "mark_ready":
|
|
200
|
-
return
|
|
224
|
+
return [header, "", result.log].join("\n");
|
|
225
|
+
case "rebase":
|
|
226
|
+
return [
|
|
227
|
+
header,
|
|
228
|
+
"",
|
|
229
|
+
result.rebase.reason,
|
|
230
|
+
"",
|
|
231
|
+
"```bash",
|
|
232
|
+
result.rebase.shellScript,
|
|
233
|
+
"```",
|
|
234
|
+
].join("\n");
|
|
201
235
|
case "escalate":
|
|
202
|
-
return
|
|
236
|
+
return [header, "", result.escalate.humanMessage].join("\n");
|
|
237
|
+
case "fix_code":
|
|
238
|
+
return formatFixCodeResult(header, result);
|
|
203
239
|
}
|
|
204
240
|
}
|
|
241
|
+
function formatFixCodeResult(header, result) {
|
|
242
|
+
const sections = [header];
|
|
243
|
+
if (result.fix.threads.length > 0) {
|
|
244
|
+
sections.push("## Review threads");
|
|
245
|
+
for (const t of result.fix.threads) {
|
|
246
|
+
const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
|
|
247
|
+
sections.push(`### \`${t.id}\` — ${loc} (@${t.author})`);
|
|
248
|
+
sections.push(blockquote(t.body));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (result.fix.actionableComments.length > 0) {
|
|
252
|
+
sections.push("## Actionable comments");
|
|
253
|
+
for (const c of result.fix.actionableComments) {
|
|
254
|
+
sections.push(`### \`${c.id}\` (@${c.author})`);
|
|
255
|
+
sections.push(blockquote(c.body));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (result.fix.checks.length > 0) {
|
|
259
|
+
sections.push("## Failing checks");
|
|
260
|
+
const bullets = result.fix.checks.map((ch) => {
|
|
261
|
+
const kind = ch.failureKind ?? "actionable";
|
|
262
|
+
if (ch.runId)
|
|
263
|
+
return `- \`${ch.runId}\` — \`${ch.name}\` (${kind})`;
|
|
264
|
+
if (ch.detailsUrl)
|
|
265
|
+
return `- external \`${ch.detailsUrl}\` — \`${ch.name}\` (${kind})`;
|
|
266
|
+
return `- (no runId) — \`${ch.name}\` (${kind})`;
|
|
267
|
+
});
|
|
268
|
+
sections.push(bullets.join("\n"));
|
|
269
|
+
}
|
|
270
|
+
if (result.fix.changesRequestedReviews.length > 0) {
|
|
271
|
+
sections.push("## Changes-requested reviews");
|
|
272
|
+
sections.push(result.fix.changesRequestedReviews.map((r) => `- \`${r.id}\` (@${r.author})`).join("\n"));
|
|
273
|
+
}
|
|
274
|
+
if (result.fix.noiseCommentIds.length > 0) {
|
|
275
|
+
sections.push("## Noise (minimize only)");
|
|
276
|
+
sections.push(result.fix.noiseCommentIds.map((id) => `\`${id}\``).join(", "));
|
|
277
|
+
}
|
|
278
|
+
if (result.cancelled.length > 0) {
|
|
279
|
+
sections.push("## Cancelled runs");
|
|
280
|
+
sections.push(result.cancelled.map((id) => `\`${id}\``).join(", "));
|
|
281
|
+
}
|
|
282
|
+
sections.push("## Rebase");
|
|
283
|
+
sections.push([
|
|
284
|
+
`- base: \`${result.baseBranch}\``,
|
|
285
|
+
`- resolve: \`${renderResolveCommand(result.fix.resolveCommand)}\``,
|
|
286
|
+
].join("\n"));
|
|
287
|
+
if (result.fix.instructions.length > 0) {
|
|
288
|
+
sections.push("## Instructions");
|
|
289
|
+
sections.push(result.fix.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
|
|
290
|
+
}
|
|
291
|
+
return sections.join("\n\n");
|
|
292
|
+
}
|
|
293
|
+
function blockquote(body) {
|
|
294
|
+
return body
|
|
295
|
+
.split("\n")
|
|
296
|
+
.map((line) => (line === "" ? ">" : `> ${line}`))
|
|
297
|
+
.join("\n");
|
|
298
|
+
}
|
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);
|
|
@@ -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 "
|
|
146
|
+
return "PENDING";
|
|
143
147
|
if (mergeStatus.status === "UNKNOWN")
|
|
144
148
|
return "UNKNOWN";
|
|
145
149
|
if (changesRequestedReviews > 0)
|