pr-shepherd 0.7.1 → 0.8.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.
Files changed (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -303
  3. package/bin/checks/classify.mjs +5 -4
  4. package/bin/checks/triage.mjs +76 -62
  5. package/bin/cli/args.mjs +29 -61
  6. package/bin/cli/exit-codes.mjs +39 -0
  7. package/bin/cli/fix-formatter.mjs +76 -0
  8. package/bin/cli/formatters.mjs +108 -0
  9. package/bin/cli/handlers.mjs +138 -0
  10. package/bin/cli/iterate-formatter.mjs +78 -0
  11. package/bin/cli-parser.iterate-fixtures.mjs +70 -0
  12. package/bin/cli-parser.mjs +110 -0
  13. package/bin/commands/check-status.mjs +35 -0
  14. package/bin/commands/check.mjs +14 -61
  15. package/bin/commands/commit-suggestion.mjs +159 -0
  16. package/bin/commands/iterate/classify.mjs +77 -0
  17. package/bin/commands/iterate/escalate.mjs +124 -0
  18. package/bin/commands/iterate/fix-code.mjs +97 -0
  19. package/bin/commands/iterate/helpers.mjs +103 -0
  20. package/bin/commands/iterate/index.mjs +124 -0
  21. package/bin/commands/iterate/render.mjs +119 -0
  22. package/bin/commands/iterate/stall.mjs +65 -0
  23. package/bin/commands/iterate/steps.mjs +31 -0
  24. package/bin/commands/iterate.mjs +2 -628
  25. package/bin/commands/monitor.mjs +78 -0
  26. package/bin/commands/ready-delay.mjs +3 -4
  27. package/bin/commands/resolve-instructions.mjs +39 -0
  28. package/bin/commands/resolve.mjs +34 -3
  29. package/bin/commands/status.mjs +7 -0
  30. package/bin/comments/resolve.mjs +1 -1
  31. package/bin/config/load.mjs +17 -113
  32. package/bin/config.json +10 -22
  33. package/bin/github/batch-parsers.mjs +140 -0
  34. package/bin/github/batch-raw-types.mjs +2 -0
  35. package/bin/github/batch.mjs +34 -129
  36. package/bin/github/client.mjs +47 -9
  37. package/bin/github/gql/batch-pr.gql +20 -0
  38. package/bin/github/http.mjs +32 -30
  39. package/bin/index.mjs +15 -2
  40. package/bin/merge-status/derive.mjs +11 -11
  41. package/bin/reporters/agent.mjs +13 -4
  42. package/bin/reporters/check-instructions.mjs +65 -0
  43. package/bin/reporters/json.mjs +3 -2
  44. package/bin/reporters/text.mjs +108 -61
  45. package/bin/{cache → state}/fix-attempts.mjs +3 -3
  46. package/bin/state/iterate-stall.mjs +74 -0
  47. package/bin/suggestions/parse.mjs +119 -0
  48. package/bin/suggestions/patch.mjs +52 -0
  49. package/bin/types/github.mjs +2 -0
  50. package/bin/types/iterate.mjs +2 -0
  51. package/bin/types/report.mjs +2 -0
  52. package/bin/types.mjs +3 -1
  53. package/package.json +3 -3
  54. package/plugin/skills/check/SKILL.md +15 -48
  55. package/plugin/skills/monitor/SKILL.md +11 -64
  56. package/plugin/skills/resolve/SKILL.md +10 -76
  57. package/bin/cache/file-cache.mjs +0 -79
  58. package/bin/cli.mjs +0 -298
@@ -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.7.1",
4
+ "version": "0.8.1",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -1,16 +1,26 @@
1
1
  # pr-shepherd
2
2
 
3
3
  Autonomous PR CI monitor and review-comment resolver for Claude Code.
4
+ The goal is to have an agent take a plan to a human-reviewable PR autonomously.
5
+
6
+ Example Workflow:
7
+
8
+ 1. `/model opusplan`
9
+ 2. Create a plan
10
+ 3. Accept the plan
11
+ 4. Switch to Auto Mode
12
+ 5. Prompt: `make a PR, then run /pr-shepherd:monitor`
13
+ 6. ...
14
+ 7. Human reviews PR with passing CI, no open threads, and all comments minimized
4
15
 
5
16
  ## Why pr-shepherd
6
17
 
7
18
  Concrete improvements to an agentic PR-review workflow:
8
19
 
9
20
  - **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
21
+ - **Lower context usage per iteration** — classification lives in the CLI; the agent receives one decision per tick and never sees raw GraphQL payloads or resolved threads
12
22
  - **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))
23
+ - **Reduced GitHub rate-limit exposure** — one batched GraphQL read per tick; loop-state files (fix-attempts, stall detection, ready-delay timer) are kept in `$TMPDIR/pr-shepherd-state/`
14
24
  - **No MCP surface** — skills call the CLI via `npx`; no long-lived MCP server, no extra auth boundary, smaller reasoning surface
15
25
  - **Skills over subagents** — skill prompts inject into the main conversation rather than spawning a subagent that reloads CLAUDE.md every turn
16
26
  - **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
@@ -20,130 +30,9 @@ Concrete improvements to an agentic PR-review workflow:
20
30
  - **Reduced agent context** — logic lives in the CLI, not the prompt
21
31
  - **Reduced GitHub rate-limit exhaustion** — primary PR state is fetched via a batched GraphQL query
22
32
  - **Fewer tool calls** — comment resolutions are batched; resolved threads never reach the agent
23
- - **No MCP** — smaller reasoning surface, much faster than the GitHub MCP
24
- - **No vendor lock-in** — runs against `gh` + `git`; no hosted service required
25
33
  - **Skills over subagents** — subagents reload all CLAUDE.md context on every turn; skills inject into the main conversation instead, keeping cost low
26
34
  - **JSON/text parity** — `--format=json` and `--format=text` carry equivalent information; every field in one has a representation in the other
27
35
 
28
- ## Features
29
-
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.
52
-
53
- ## Install
54
-
55
- > **Note:** Skill and plugin install methods add the skill definitions only — they do not install the `pr-shepherd` CLI. The skills invoke `npx pr-shepherd`, so you also need the CLI available. If you're using `pr-shepherd` as development tooling for your repo, install it as a dev dependency so `npx` resolves it without prompting:
56
- >
57
- > ```bash
58
- > npm install --save-dev pr-shepherd
59
- > ```
60
- >
61
- > A plain `npm install pr-shepherd` adds it to regular dependencies instead; use that only if you specifically want it under `dependencies`. Or install globally: `npm install -g pr-shepherd`.
62
-
63
- ### As individual skills via `npx skills`
64
-
65
- ```bash
66
- npx skills add jonathanong/pr-shepherd
67
- ```
68
-
69
- Installs the three skills (`check`, `monitor`, `resolve`) into your agent's skill directory (`.claude/skills/` for project scope, `~/.claude/skills/` with `-g` for global scope). Powered by [skills.sh](https://skills.sh).
70
-
71
- ### As a Claude Code plugin (recommended)
72
-
73
- ```bash
74
- claude /plugin marketplace add jonathanong/pr-shepherd
75
- claude /plugin install pr-shepherd
76
- ```
77
-
78
- This repo ships two `marketplace.json` files that serve different install flows: the root `marketplace.json` resolves the plugin from the npm registry (used by the `claude /plugin marketplace add` command above); `.claude-plugin/marketplace.json` is the owner-level registry manifest that resolves the plugin from the local plugin directory (used when Claude Code installs from a local or git-based source). Both files are needed to support these two install paths.
79
-
80
- See [Usage](#usage) below.
81
-
82
- ### Without the plugin — custom slash command
83
-
84
- If you don't want the full plugin, create a project-local (or user-scope)
85
- slash command that wraps the CLI directly. This still requires `pr-shepherd`
86
- to be installed in the repository first (`npm install pr-shepherd`), so that
87
- `npx pr-shepherd ...` runs without prompting to install the package.
88
-
89
- 1. **Create the command file:**
90
- - Project-scope: `.claude/commands/pr-check.md`
91
- - User-scope: `~/.claude/commands/pr-check.md`
92
-
93
- 2. **Paste this as the file contents:**
94
-
95
- ````markdown
96
- ---
97
- description: "Check GitHub CI status and review comments for the current PR"
98
- argument-hint: "[PR number or URL ...]"
99
- allowed-tools: ["Bash", "Read", "Grep"]
100
- ---
101
-
102
- # PR Status Check
103
-
104
- ## Arguments: $ARGUMENTS
105
-
106
- ## Resolve PR number(s)
107
-
108
- 1. If `$ARGUMENTS` contains PR numbers or GitHub PR URLs, extract the number(s).
109
- 2. Otherwise, infer: `gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number --jq '.[0].number'`
110
- 3. If no PR found, report an error and stop.
111
-
112
- ## Run the check
113
-
114
- ```bash
115
- npx pr-shepherd check <PR_NUMBER> --format=json
116
- ```
117
-
118
- Parse the JSON and report:
119
-
120
- - **Merge status** (`report.mergeStatus.status`): CLEAN | BEHIND | CONFLICTS | BLOCKED | UNSTABLE | DRAFT | UNKNOWN
121
- - **CI check results** (`report.checks`): passing count, failing names, in-progress names
122
- - **Unresolved review comments** (`report.threads.actionable` + `report.comments.actionable`): count + details
123
- ````
124
-
125
- 3. **Use it in Claude Code:**
126
-
127
- ```
128
- /pr-check
129
- /pr-check 42
130
- ```
131
-
132
- For `monitor` and `resolve` custom commands, do **not** copy the
133
- [`plugin/skills/`](plugin/skills/) files directly — those contain skill/plugin-specific
134
- frontmatter that is not valid for `.claude/commands/` files. Instead, create
135
- `.claude/commands/pr-monitor.md` and/or `.claude/commands/pr-resolve.md`
136
- using the same command-file structure as the `pr-check` example above, with
137
- the CLI invocation changed to `npx pr-shepherd iterate ...` or
138
- `npx pr-shepherd resolve ...`. To drive the CLI without Claude at all, see
139
- [docs/usage.md](docs/usage.md).
140
-
141
- ### As a global CLI
142
-
143
- ```bash
144
- npm install -g pr-shepherd
145
- ```
146
-
147
36
  ## Usage
148
37
 
149
38
  ### Monitor a PR
@@ -185,175 +74,45 @@ See [docs/skills.md](docs/skills.md) for full argument reference.
185
74
 
186
75
  ## Workflow
187
76
 
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.
189
-
190
- ## CLI
77
+ 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/iterate-flow.md](docs/iterate-flow.md) for the decision table and [docs/flow.md](docs/flow.md) for the end-to-end flow diagram.
191
78
 
192
- ```sh
193
- pr-shepherd -v|--version # print installed version
194
- pr-shepherd check [PR] # read-only PR status snapshot
195
- pr-shepherd resolve [PR] [--fetch | --resolve-thread-ids …]
196
- pr-shepherd iterate [PR] [--cooldown-seconds N] [--ready-delay Nm] [--last-push-time N]
197
- pr-shepherd status PR1 [PR2 …] # multi-PR table
198
- ```
199
-
200
- Common flags (all subcommands):
201
-
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 |
207
-
208
- ### pr-shepherd check [PR]
209
-
210
- Read-only PR status snapshot. Fetches CI results, merge state, and review comments in one GraphQL batch. PR number is inferred from the current branch when omitted.
211
-
212
- ```sh
213
- pr-shepherd check # infer PR from current branch
214
- pr-shepherd check 42
215
- pr-shepherd check 42 --format=json
216
- pr-shepherd check 42 --no-cache
217
- ```
218
-
219
- Exit codes: `0` READY · `2` IN_PROGRESS · `3` UNRESOLVED_COMMENTS · `1` all other statuses
220
-
221
- **Example output:**
222
-
223
- ```
224
- PR #42 — owner/repo
225
- Status: UNRESOLVED_COMMENTS
226
-
227
- Merge Status: CLEAN
228
- mergeStateStatus: CLEAN
229
- mergeable: MERGEABLE
230
- reviewDecision: APPROVED
231
- isDraft: false
232
- copilotReviewInProgress:false
233
-
234
- CI Checks: 3/3 passed
235
-
236
- Actionable Review Threads (1):
237
- - threadId=RT_kwDOBxyz123 src/api.ts:47 (@reviewer)
238
- Please add error handling here
239
-
240
- Summary: 1 actionable item(s) remaining
241
- ```
242
-
243
- ### pr-shepherd resolve [PR]
244
-
245
- Two modes: **fetch** (default) auto-resolves outdated threads and returns actionable items; **mutate** resolves/minimizes/dismisses specific IDs after you push fixes.
246
-
247
- **Fetch mode:**
248
-
249
- ```sh
250
- pr-shepherd resolve # fetch + auto-resolve outdated threads
251
- pr-shepherd resolve 42 --fetch --format=json
252
- ```
253
-
254
- ```
255
- Actionable Review Threads (2):
256
- - threadId=RT_kwDOabc src/api.ts:47 (@reviewer): Please add error handling here
257
- - threadId=RT_kwDOdef src/utils.ts:12 (@bot): Consider using a const here
258
-
259
- Summary: 2 actionable item(s)
260
- ```
261
-
262
- **Mutate mode** (after pushing fixes):
263
-
264
- ```sh
265
- pr-shepherd resolve 42 \
266
- --resolve-thread-ids RT_kwDOabc,RT_kwDOdef \
267
- --minimize-comment-ids IC_kwDOxyz \
268
- --dismiss-review-ids PRR_kwDO123 \
269
- --message "Switched query to parameterized form in src/db.ts" \
270
- --require-sha $(git rev-parse HEAD)
271
- ```
272
-
273
- ```
274
- Resolved threads (2): RT_kwDOabc, RT_kwDOdef
275
- Minimized comments (1): IC_kwDOxyz
276
- Dismissed reviews (1): PRR_kwDO123
277
- ```
278
-
279
- `--require-sha` polls GitHub until the PR head matches the SHA before mutating — ensures reviewers see the fix before threads are closed. Exit code: always `0`. `--message` is required only when `--dismiss-review-ids` is set, and should describe the specific fix — it is shown to the reviewer on GitHub.
79
+ ## Install
280
80
 
281
- ### pr-shepherd iterate [PR]
81
+ > **Note:** Skill and plugin install methods add the skill definitions only — they do not install the `pr-shepherd` CLI. The skills invoke `npx pr-shepherd`, so you also need the CLI available. If you're using `pr-shepherd` as development tooling for your repo, install it as a dev dependency so `npx` resolves it without prompting:
82
+ >
83
+ > ```bash
84
+ > npm install --save-dev pr-shepherd
85
+ > ```
86
+ >
87
+ > A plain `npm install pr-shepherd` adds it to regular dependencies instead; use that only if you specifically want it under `dependencies`. Or install globally: `npm install -g pr-shepherd`.
282
88
 
283
- One monitor tick: classifies current PR state and emits a single action. Used by the cron loop; the monitor skill calls this every 4 minutes and acts on the result. See [docs/iterate-flow.md](docs/iterate-flow.md) for the full decision tree.
89
+ ### As individual skills via `npx skills`
284
90
 
285
- ```sh
286
- pr-shepherd iterate 42 --no-cache --format=json \
287
- --ready-delay 10m \
288
- --last-push-time "$(git log -1 --format=%ct HEAD)"
91
+ ```bash
92
+ npx skills add jonathanong/pr-shepherd
289
93
  ```
290
94
 
291
- Flags:
292
-
293
- | Flag | Default | Description |
294
- | ----------------------------- | ------- | ------------------------------------------------- |
295
- | `--ready-delay Nm` | `10m` | Settle window before the loop cancels after READY |
296
- | `--cooldown-seconds N` | `30` | Wait after a push before reading CI |
297
- | `--last-push-time N` | — | Unix timestamp hint embedded in the result |
298
- | `--no-auto-rerun` | false | Return `wait` instead of rerunning transient CI |
299
- | `--no-auto-mark-ready` | false | Skip converting draft → ready-for-review |
300
- | `--no-auto-cancel-actionable` | false | Skip cancelling actionable failing runs |
301
-
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]`:
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
95
+ Installs the three skills (`check`, `monitor`, `resolve`) into your agent's skill directory (`.claude/skills/` for project scope, `~/.claude/skills/` with `-g` for global scope). Powered by [skills.sh](https://skills.sh).
309
96
 
310
- WAIT: 3 passing, 0 in-progress 540s until auto-cancel
311
- ```
97
+ ### As a Claude Code plugin (recommended)
312
98
 
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.
316
-
317
- **JSON output** (`--format=json`, compact single line):
318
-
319
- ```json
320
- {
321
- "pr": 42,
322
- "repo": "owner/repo",
323
- "status": "READY",
324
- "state": "OPEN",
325
- "mergeStateStatus": "CLEAN",
326
- "copilotReviewInProgress": false,
327
- "isDraft": false,
328
- "shouldCancel": false,
329
- "remainingSeconds": 540,
330
- "summary": { "passing": 3, "skipped": 0, "filtered": 0, "inProgress": 0 },
331
- "action": "wait"
332
- }
99
+ ```bash
100
+ claude /plugin marketplace add jonathanong/pr-shepherd
101
+ claude /plugin install pr-shepherd
333
102
  ```
334
103
 
335
- Exit codes: `0` wait/cooldown/rerun_ci/mark_ready · `1` fix_code/rebase · `2` cancel · `3` escalate
336
-
337
- ### pr-shepherd status PR1 [PR2 …]
338
-
339
- Multi-PR summary table. One lightweight GraphQL query per PR, run in parallel.
104
+ This repo ships two `marketplace.json` files that serve different install flows: the root `marketplace.json` resolves the plugin from the npm registry (used by the `claude /plugin marketplace add` command above); `.claude-plugin/marketplace.json` is the owner-level registry manifest that resolves the plugin from the local plugin directory (used when Claude Code installs from a local or git-based source). Both files are needed to support these two install paths.
340
105
 
341
- ```sh
342
- pr-shepherd status 41 42 43
343
- pr-shepherd status 100 --format=json
344
- ```
106
+ ### Without the plugin
345
107
 
346
- ```
108
+ See [docs/custom-commands.md](docs/custom-commands.md) for a project-local slash command that wraps the CLI without the plugin.
347
109
 
348
- # owner/repo PR status (3)
110
+ ### As a global CLI
349
111
 
350
- PR #41 Add new feature for user authentication READY SUCCESS
351
- PR #42 Refactor internal module IN PROGRESS PENDING
352
- PR #43 Fix edge case in parser BLOCKED SUCCESS (threads truncated — run shepherd check for full count)
112
+ ```bash
113
+ npm install -g pr-shepherd
353
114
  ```
354
115
 
355
- Exit code: `0` if every PR is READY, `1` otherwise.
356
-
357
116
  ## Configuration
358
117
 
359
118
  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.
@@ -370,32 +129,7 @@ actions:
370
129
  autoRebase: false # disable for repos that enforce merge commits
371
130
  ```
372
131
 
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).
132
+ Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`), `PR_SHEPHERD_STATE_DIR` (override loop-state base dir).
399
133
 
400
134
  See [docs/configuration.md](docs/configuration.md) for full semantics and deprecated-key migration.
401
135
 
@@ -9,7 +9,6 @@
9
9
  * pass/fail tally. Report them as "skipped" for transparency but don't block on them.
10
10
  */
11
11
  import { loadConfig } from "../config/load.mjs";
12
- const RELEVANT_EVENTS = new Set(loadConfig().checks.ciTriggerEvents);
13
12
  /**
14
13
  * Classify a list of raw check runs into shepherd categories.
15
14
  *
@@ -17,11 +16,13 @@ const RELEVANT_EVENTS = new Set(loadConfig().checks.ciTriggerEvents);
17
16
  * @returns Classified checks. "filtered" items were excluded from the tally.
18
17
  */
19
18
  export function classifyChecks(checks) {
20
- return checks.map((c) => classify(c));
19
+ const relevantEvents = new Set(loadConfig().checks.ciTriggerEvents);
20
+ return checks.map((c) => classify(c, relevantEvents));
21
21
  }
22
- function classify(check) {
22
+ function classify(check, relevantEvents) {
23
23
  // Filter: runs from non-PR events don't count toward PR readiness.
24
- if (check.event !== null && !RELEVANT_EVENTS.has(check.event)) {
24
+ // event === null means a commit StatusContext — these are always relevant regardless of ciTriggerEvents.
25
+ if (check.event !== null && !relevantEvents.has(check.event)) {
25
26
  return { ...check, category: "filtered" };
26
27
  }
27
28
  const { status, conclusion } = check;
@@ -1,92 +1,106 @@
1
1
  /**
2
- * Triage failing check runs into four categories:
3
- * - timeout: conclusion is TIMED_OUT or logs contain timeout markers.
4
- * - infrastructure: conclusion is CANCELLED + infra-error log patterns.
5
- * - actionable: compile error, test failure, lint violation from the PR's changes.
6
- * - flaky: pre-existing or timing-dependent failures in untouched files.
2
+ * Triage failing check runs into three categories based solely on GitHub's
3
+ * own conclusion field no log-content classification:
4
+ * - timeout: conclusion is TIMED_OUT.
5
+ * - cancelled: conclusion is CANCELLED, STARTUP_FAILURE, or STALE.
6
+ * - actionable: everything else (FAILURE, ACTION_REQUIRED, …).
7
7
  *
8
- * Shepherd computes and returns triage results, including `failureKind`, for
9
- * downstream callers or slash-command logic to consume.
8
+ * Exception: checks with runId === null are always classified as "actionable"
9
+ * regardless of conclusion, so they surface in fix_code where the monitor
10
+ * escalates to the user (no run to rerun/inspect).
11
+ *
12
+ * For all checks with a non-null runId, the jobs API is called once per runId
13
+ * (results are cached across checks that share a run) to fetch workflow name
14
+ * and, for actionable checks, the first failed step name. No log fetching is done.
15
+ *
16
+ * Note on infrastructure-killed FAILURE runs: GitHub reports these as
17
+ * conclusion === "FAILURE", so they classify as "actionable". The jobs API
18
+ * surfaces their failedStep (e.g. "Set up job") which gives the agent a
19
+ * GitHub-native signal to distinguish runner setup deaths from real test
20
+ * failures — without any log-pattern analysis at the CLI level.
10
21
  */
11
- import { rest, restText } from "../github/http.mjs";
12
- import { loadConfig } from "../config/load.mjs";
13
- const config = loadConfig();
14
- const TIMEOUT_PATTERNS = config.checks.timeoutPatterns.map((p) => new RegExp(p, "i"));
15
- const INFRA_PATTERNS = config.checks.infraPatterns.map((p) => new RegExp(p, "i"));
22
+ import { rest } from "../github/http.mjs";
16
23
  // ---------------------------------------------------------------------------
17
24
  // Public API
18
25
  // ---------------------------------------------------------------------------
19
26
  /**
20
- * Fetch logs and triage each failing check.
27
+ * Triage each failing check: classify by GitHub conclusion and call the jobs
28
+ * API for each check with a non-null runId to fetch workflow name and (for
29
+ * actionable failures) the name of the first failed step.
21
30
  *
22
- * Fetching logs is skipped for checks that have no `runId` (e.g. StatusContext nodes).
31
+ * Jobs responses are cached by runId so checks that share a run (e.g. matrix
32
+ * builds or multiple required steps in one workflow) make only one API call.
23
33
  */
24
34
  export function triageFailingChecks(failingChecks, repo) {
25
- return Promise.all(failingChecks.map((c) => triageCheck(c, repo)));
35
+ const jobsCache = new Map();
36
+ return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache)));
26
37
  }
27
38
  // ---------------------------------------------------------------------------
28
39
  // Internal
29
40
  // ---------------------------------------------------------------------------
30
- async function triageCheck(check, repo) {
41
+ async function triageCheck(check, repo, jobsCache) {
42
+ const failureKind = check.runId === null ? "actionable" : classifyConclusion(check.conclusion);
31
43
  if (check.runId === null) {
32
- return { ...check, failureKind: "actionable" };
44
+ return { ...check, failureKind };
33
45
  }
34
- const logExcerpt = await fetchFailedLogs(check.runId, repo);
35
- const failureKind = classifyLogs(check, logExcerpt);
46
+ const jobs = await fetchJobs(check.runId, repo, jobsCache);
47
+ const jobInfo = jobs ? pickJobInfo(jobs, check.name, failureKind) : undefined;
36
48
  return {
37
49
  ...check,
38
50
  failureKind,
39
- logExcerpt: logExcerpt.slice(-config.checks.logMaxChars) || undefined,
51
+ workflowName: jobInfo?.workflowName,
52
+ ...(failureKind === "actionable" && { failedStep: jobInfo?.failedStep }),
40
53
  };
41
54
  }
42
- async function fetchFailedLogs(runId, repo) {
55
+ function classifyConclusion(c) {
56
+ if (c === "TIMED_OUT")
57
+ return "timeout";
58
+ if (c === "CANCELLED" || c === "STARTUP_FAILURE" || c === "STALE")
59
+ return "cancelled";
60
+ return "actionable";
61
+ }
62
+ function fetchJobs(runId, repo, cache) {
63
+ const cached = cache.get(runId);
64
+ if (cached)
65
+ return cached;
66
+ const promise = fetchJobsUncached(runId, repo);
67
+ cache.set(runId, promise);
68
+ return promise;
69
+ }
70
+ async function fetchJobsUncached(runId, repo) {
71
+ const { owner, name } = repo;
72
+ const perPage = 100;
73
+ const MAX_JOB_PAGES = 20; // 2000 jobs max
74
+ let pagesFetched = 0;
75
+ const allJobs = [];
43
76
  try {
44
- const { owner, name } = repo;
45
- const perPage = 100;
46
- const allJobs = [];
47
77
  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)
78
+ if (++pagesFetched > MAX_JOB_PAGES) {
79
+ process.stderr.write(`pr-shepherd: job pagination cap (${MAX_JOB_PAGES * 100} jobs) reached for run ${runId} — triage may be incomplete\n`);
51
80
  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
81
  }
63
- catch {
64
- return "";
65
- }
66
- }));
67
- const combined = logParts.filter(Boolean).join("\n");
68
- const ansiEscapes = /\u001B\[[0-9;]*m/g;
69
- return combined
70
- .replace(ansiEscapes, "")
71
- .split("\n")
72
- .slice(-config.checks.logMaxLines)
73
- .join("\n");
82
+ const data = await rest("GET", `/repos/${owner}/${name}/actions/runs/${runId}/jobs?filter=latest&per_page=${perPage}&page=${page}`);
83
+ allJobs.push(...data.jobs);
84
+ if (data.jobs.length < perPage)
85
+ break;
86
+ }
74
87
  }
75
88
  catch {
76
- return "";
89
+ return undefined;
77
90
  }
91
+ return allJobs;
78
92
  }
79
- function classifyLogs(check, logs) {
80
- if (check.conclusion === "TIMED_OUT")
81
- return "timeout";
82
- if (TIMEOUT_PATTERNS.some((re) => re.test(logs)))
83
- return "timeout";
84
- if (check.conclusion === "CANCELLED" && INFRA_PATTERNS.some((re) => re.test(logs))) {
85
- return "infrastructure";
86
- }
87
- if (!logs.trim())
88
- return "infrastructure";
89
- if (/flaky|timing|race condition|retry/i.test(logs))
90
- return "flaky";
91
- return "actionable";
93
+ function pickJobInfo(jobs, checkName, failureKind) {
94
+ // Match by name. For matrix jobs sharing a check name, prefer a failing one.
95
+ // Fall back to prefix matching for matrix jobs whose workflow-API name includes
96
+ // a suffix like "(ubuntu)" while checkName is just the base name.
97
+ const exactMatches = jobs.filter((j) => j.name === checkName);
98
+ const matchedJobs = exactMatches.length > 0 ? exactMatches : jobs.filter((j) => j.name.startsWith(checkName));
99
+ const job = matchedJobs.find((j) => j.conclusion === "failure") ?? matchedJobs[0];
100
+ if (!job)
101
+ return undefined;
102
+ const failedStep = failureKind === "actionable"
103
+ ? job.steps?.find((s) => s.conclusion === "failure")?.name
104
+ : undefined;
105
+ return { workflowName: job.workflow_name, failedStep };
92
106
  }