pr-shepherd 0.2.0 → 0.4.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.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +8 -2
  3. package/README.md +128 -83
  4. package/bin/cache/file-cache.mjs +79 -0
  5. package/bin/cache/fix-attempts.mjs +67 -0
  6. package/bin/checks/classify.mjs +53 -0
  7. package/bin/checks/triage.mjs +77 -0
  8. package/bin/cli/args.mjs +173 -0
  9. package/bin/cli.mjs +204 -0
  10. package/bin/commands/check.mjs +140 -0
  11. package/bin/commands/iterate.mjs +301 -0
  12. package/bin/commands/ready-delay.mjs +87 -0
  13. package/bin/commands/resolve.mjs +64 -0
  14. package/bin/commands/status.mjs +107 -0
  15. package/{src/comments/outdated.mts → bin/comments/outdated.mjs} +2 -5
  16. package/bin/comments/resolve.mjs +111 -0
  17. package/bin/config/load.mjs +158 -0
  18. package/bin/github/batch.mjs +208 -0
  19. package/bin/github/client.mjs +152 -0
  20. package/{src/github/pagination.mts → bin/github/pagination.mjs} +26 -52
  21. package/{src/github/queries.mts → bin/github/queries.mjs} +1 -10
  22. package/{src/index.mts → bin/index.mjs} +3 -5
  23. package/bin/merge-status/derive.mjs +72 -0
  24. package/bin/pr-shepherd +2 -0
  25. package/bin/reporters/agent.mjs +41 -0
  26. package/{src/reporters/json.mts → bin/reporters/json.mjs} +2 -5
  27. package/bin/reporters/text.mjs +111 -0
  28. package/bin/types.mjs +2 -0
  29. package/package.json +9 -9
  30. package/skills/check/SKILL.md +12 -14
  31. package/skills/monitor/SKILL.md +9 -5
  32. package/src/cache/file-cache.mts +0 -101
  33. package/src/cache/file-cache.test.mts +0 -91
  34. package/src/cache/fix-attempts.mts +0 -86
  35. package/src/checks/classify.mts +0 -80
  36. package/src/checks/classify.test.mts +0 -164
  37. package/src/checks/triage.mock.test.mts +0 -202
  38. package/src/checks/triage.mts +0 -88
  39. package/src/cli.mts +0 -423
  40. package/src/commands/check.mts +0 -188
  41. package/src/commands/iterate.mock.test.mts +0 -1111
  42. package/src/commands/iterate.mts +0 -371
  43. package/src/commands/ready-delay.mts +0 -117
  44. package/src/commands/ready-delay.test.mts +0 -116
  45. package/src/commands/resolve.mts +0 -92
  46. package/src/commands/status.mts +0 -173
  47. package/src/comments/resolve.mts +0 -179
  48. package/src/config/load.mts +0 -240
  49. package/src/github/batch.mts +0 -351
  50. package/src/github/client.mts +0 -207
  51. package/src/github/client.test.mts +0 -19
  52. package/src/github/pagination.test.mts +0 -140
  53. package/src/merge-status/derive.mts +0 -74
  54. package/src/merge-status/derive.test.mts +0 -130
  55. package/src/reporters/text.mts +0 -140
  56. package/src/types.mts +0 -309
  57. /package/{src → bin}/config.json +0 -0
  58. /package/{src → bin}/github/gql/batch-pr.gql +0 -0
  59. /package/{src → bin}/github/gql/dismiss-review.gql +0 -0
  60. /package/{src → bin}/github/gql/minimize-comment.gql +0 -0
  61. /package/{src → bin}/github/gql/multi-pr-status-paged.gql +0 -0
  62. /package/{src → bin}/github/gql/multi-pr-status.gql +0 -0
  63. /package/{src → bin}/github/gql/resolve-thread.gql +0 -0
  64. /package/{src/util/path-segment.mts → bin/util/path-segment.mjs} +0 -0
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3
+ "name": "jonathanong",
4
+ "description": "Jonathan Ong's Claude Code plugins",
5
+ "owner": {
6
+ "name": "Jonathan Ong",
7
+ "email": "jonathanrichardong@gmail.com"
8
+ },
9
+ "plugins": [
10
+ {
11
+ "name": "pr-shepherd",
12
+ "description": "Autonomous PR CI monitor and review-comment resolver",
13
+ "source": ".claude-plugin",
14
+ "category": "productivity",
15
+ "homepage": "https://github.com/jonathanong/pr-shepherd"
16
+ }
17
+ ]
18
+ }
@@ -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.1.0",
4
+ "version": "0.4.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -9,6 +9,12 @@
9
9
  "homepage": "https://github.com/jonathanong/pr-shepherd",
10
10
  "repository": "https://github.com/jonathanong/pr-shepherd",
11
11
  "license": "MIT",
12
- "keywords": ["github", "pull-request", "ci", "code-review", "automation"],
12
+ "keywords": [
13
+ "github",
14
+ "pull-request",
15
+ "ci",
16
+ "code-review",
17
+ "automation"
18
+ ],
13
19
  "skills": "./skills/"
14
20
  }
package/README.md CHANGED
@@ -2,99 +2,144 @@
2
2
 
3
3
  Autonomous PR CI monitor and review-comment resolver for Claude Code.
4
4
 
5
- ## Goals
6
-
7
- - **Reduced context** — shifts more logic to the CLI instead of the agent
8
- - **Reduced GitHub rate limit exhaustion** — all GraphQL queries are batched
9
- - **Reduced agent tool calls** — batching comment resolutions means fewer tool calls and less context used
10
- - **No MCP** — less reasoning and much faster than using the GitHub MCP
11
- - **CI cancellation on failure** — avoids wasted CI runs when actionable failures exist
12
- - **Auto-resolution of all inline comments** including bot and AI reviewer comments
13
- - **Automatic resolution of outdated comments** — happens before the agent is involved
14
- - **Automatic pagination and filtering** — resolved comments never reach the agent
15
- - **Aggressively hides bot comments** — keeps PR noise low
16
- - **Waits for pending Copilot reviews** — avoids premature marking as ready
17
- - **Rebases on conflict** — automatically rebases on the PR base branch when there are merge conflicts
18
- - **4-minute watch cadence** — keeps Claude's prompt cache warm (5-minute TTL)
19
- - **10-minute settle window** — waits after the PR is clean before exiting, in case of pending reviews
20
- - **Draft → ready-for-review** — automatically converts draft PRs when CI passes
21
- - **Skips non-PR CI checks** — only `pull_request` / `pull_request_target` events count toward readiness
22
- - **Intended as a PR merge blocker** — pair with a GitHub Actions required check that verifies all threads are resolved
5
+ ## Design principles
6
+
7
+ - **Reduced agent context** — logic lives in the CLI, not the prompt
8
+ - **Reduced GitHub rate-limit exhaustion** — primary PR state is fetched via a batched GraphQL query
9
+ - **Fewer tool calls** — comment resolutions are batched; resolved threads never reach the agent
10
+ - **No MCP** — smaller reasoning surface, much faster than the GitHub MCP
11
+ - **No vendor lock-in** — runs against `gh` + `git`; no hosted service required
12
+ - **Skills over subagents** — subagents reload all CLAUDE.md context on every turn; skills inject into the main conversation instead, keeping cost low
23
13
 
24
- ## Why it's built this way
14
+ ## Features
25
15
 
26
- Claude's cloud autofix requires CI to verify changes for apps that can't run in the cloud. Running targeted tests locally and letting Claude Code drive is cheaper and avoids vendor lock-in. Skills are used (not subagents) because subagents load all CLAUDE.md context, increasing cost; skills inject into the main conversation instead.
16
+ - **CI handling** cancels runs on actionable failures, reruns on transient/infra failures, skips non-PR trigger events
17
+ - **Comments** — resolves inline threads (including bot and AI reviewer comments), auto-resolves outdated threads before the agent sees them, aggressively hides bot comments, paginates and filters server-side
18
+ - **Readiness** — converts draft → ready-for-review when CI passes, waits for pending Copilot reviews, settles for a configurable window (default 10 min) before exiting
19
+ - **Rebases on conflict** — automatically rebases on the PR base branch when merge conflicts appear
20
+ - **Intended as a PR merge blocker** — pair with a GitHub Actions required check that verifies all threads are resolved
27
21
 
28
22
  ## Install
29
23
 
24
+ ### As a Claude Code plugin (recommended)
25
+
30
26
  ```bash
31
- npm install pr-shepherd
27
+ claude /plugin marketplace add jonathanong/pr-shepherd
28
+ claude /plugin install pr-shepherd
32
29
  ```
33
30
 
34
- ### As a Claude Code plugin
31
+ 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.
32
+
33
+ See [Usage](#usage) below.
34
+
35
+ ### Without the plugin — custom slash command
36
+
37
+ If you don't want the full plugin, create a project-local (or user-scope)
38
+ slash command that wraps the CLI directly. This still requires `pr-shepherd`
39
+ to be installed in the repository first (`npm install pr-shepherd`), so that
40
+ `npx pr-shepherd ...` runs without prompting to install the package.
41
+
42
+ 1. **Create the command file:**
43
+ - Project-scope: `.claude/commands/pr-check.md`
44
+ - User-scope: `~/.claude/commands/pr-check.md`
45
+
46
+ 2. **Paste this as the file contents:**
47
+
48
+ ````markdown
49
+ ---
50
+ description: "Check GitHub CI status and review comments for the current PR"
51
+ argument-hint: "[PR number or URL ...]"
52
+ allowed-tools: ["Bash", "Read", "Grep"]
53
+ ---
54
+
55
+ # PR Status Check
56
+
57
+ ## Arguments: $ARGUMENTS
58
+
59
+ ## Resolve PR number(s)
60
+
61
+ 1. If `$ARGUMENTS` contains PR numbers or GitHub PR URLs, extract the number(s).
62
+ 2. Otherwise, infer: `gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number --jq '.[0].number'`
63
+ 3. If no PR found, report an error and stop.
64
+
65
+ ## Run the check
66
+
67
+ ```bash
68
+ npx pr-shepherd check <PR_NUMBER> --format=json
69
+ ```
70
+
71
+ Parse the JSON and report:
72
+
73
+ - **Merge status** (`report.mergeStatus.status`): CLEAN | BEHIND | CONFLICTS | BLOCKED | UNSTABLE | DRAFT | UNKNOWN
74
+ - **CI check results** (`report.checks`): passing count, failing names, in-progress names
75
+ - **Unresolved review comments** (`report.threads.actionable` + `report.comments.actionable`): count + details
76
+ ````
77
+
78
+ 3. **Use it in Claude Code:**
79
+
80
+ ```
81
+ /pr-check
82
+ /pr-check 42
83
+ ```
84
+
85
+ For `monitor` and `resolve` custom commands, do **not** copy the
86
+ [`skills/`](skills/) files directly — those contain skill/plugin-specific
87
+ frontmatter that is not valid for `.claude/commands/` files. Instead, create
88
+ `.claude/commands/pr-monitor.md` and/or `.claude/commands/pr-resolve.md`
89
+ using the same command-file structure as the `pr-check` example above, with
90
+ the CLI invocation changed to `npx pr-shepherd iterate ...` or
91
+ `npx pr-shepherd resolve ...`. To drive the CLI without Claude at all, see
92
+ [docs/usage.md](docs/usage.md).
93
+
94
+ ### As a global CLI
35
95
 
36
96
  ```bash
37
- # Install from marketplace
38
- claude /plugin marketplace add jonathanong/pr-shepherd
39
- claude /plugin install pr-shepherd
97
+ npm install -g pr-shepherd
40
98
  ```
41
99
 
42
- Then use:
100
+ ## Usage
43
101
 
44
- - `/pr-shepherd:monitor [PR]` — start continuous monitoring
45
- - `/pr-shepherd:check [PR]` — one-shot status check
46
- - `/pr-shepherd:resolve [PR]` — fetch, fix, and resolve review comments
102
+ ### Monitor a PR
47
103
 
48
- ## Workflow
104
+ Creates a cron loop that fires every 4 minutes, checks CI and review
105
+ comments, fixes issues, and marks the PR ready for review when clean. The
106
+ loop cancels automatically when the PR is merged or closed.
107
+
108
+ ```
109
+ /pr-shepherd:monitor # infer PR from current branch
110
+ /pr-shepherd:monitor 42
111
+ /pr-shepherd:monitor 42 every 8m
112
+ /pr-shepherd:monitor 42 --ready-delay 15m
113
+ ```
114
+
115
+ ### Check a PR
116
+
117
+ One-shot status snapshot — merge state, CI results, and unresolved comments.
118
+ Accepts multiple PR numbers.
119
+
120
+ ```
121
+ /pr-shepherd:check # infer from branch
122
+ /pr-shepherd:check 42
123
+ /pr-shepherd:check 41 42 43
124
+ ```
125
+
126
+ ### Resolve review comments
49
127
 
50
- ```mermaid
51
- flowchart TD
52
- U(["/pr-shepherd:monitor PR"]) --> SC["monitor skill"]
53
- SC -->|CronList| EX{Loop exists<br/>for this PR?}
54
- EX -->|yes| NOW[Run iterate once<br/>inline and act]
55
- EX -->|no| CREATE["/loop 4m --max-turns 50 --expires 8h"]
56
- CREATE --> CRON[(cron tick every 4m)]
57
- NOW --> ITER
58
- CRON --> ITER["pr-shepherd iterate PR --format=json"]
59
-
60
- ITER --> S1{1. last commit<br/>age &lt; cooldown?}
61
- S1 -->|yes| A_COOL([action: cooldown])
62
- S1 -->|no| S2["2. runCheck — one GraphQL batch<br/>classify + deriveMergeStatus<br/>+ autoResolveOutdated"]
63
-
64
- S2 --> S25{2.5 state != OPEN?}
65
- S25 -->|yes| A_CAN([action: cancel])
66
- S25 -->|no| S3["3. updateReadyDelay<br/>ready-since.txt"]
67
- S3 --> S3C{shouldCancel?}
68
- S3C -->|yes| A_CAN
69
- S3C -->|no| S4{4. CONFLICTS or actionable<br/>threads/comments/CI/reviews?}
70
- S4 -->|yes| S4X["gh run cancel actionable runIds"]
71
- S4X --> A_FIX([action: fix_code])
72
- S4 -->|no| S5{5. transient<br/>timeout/infra?}
73
- S5 -->|yes| S5X["gh run rerun runId --failed"]
74
- S5X --> A_RR([action: rerun_ci])
75
- S5 -->|no| S6{6. flaky + BEHIND?}
76
- S6 -->|yes| A_REB([action: rebase])
77
- S6 -->|no| S7{7. READY + CLEAN<br/>+ isDraft + !copilot?}
78
- S7 -->|yes| A_MR([action: mark_ready])
79
- S7 -->|no| A_W([action: wait])
80
-
81
- A_COOL --> DEC{skill acts on action}
82
- A_CAN --> DEC
83
- A_REB --> DEC
84
- A_FIX --> DEC
85
- A_RR --> DEC
86
- A_MR --> DEC
87
- A_W --> DEC
88
-
89
- DEC -->|cancel| STOP["/loop cancel"]
90
- DEC -->|rebase| REB["git fetch && rebase origin/BASE &&<br/>push --force-with-lease"]
91
- DEC -->|fix_code| FIX["Edit files →<br/>git add + commit →<br/>fetch + rebase + push →<br/>pr-shepherd resolve --require-sha HEAD"]
92
- FIX --> NEXT[Wait for next tick]
93
- REB --> NEXT
94
- DEC -->|other| NEXT
95
- NEXT --> CRON
128
+ Fetches all actionable threads and comments, triages them, applies fixes,
129
+ pushes, then resolves/minimizes/dismisses via `--require-sha` (waits until
130
+ GitHub has seen the push before resolving).
131
+
132
+ ```
133
+ /pr-shepherd:resolve # infer from branch
134
+ /pr-shepherd:resolve 42
96
135
  ```
97
136
 
137
+ See [docs/skills.md](docs/skills.md) for full argument reference.
138
+
139
+ ## Workflow
140
+
141
+ On each 4-minute tick: fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (fix code, rebase, rerun CI, mark ready, or wait). See [docs/flow.md](docs/flow.md) for the full decision tree.
142
+
98
143
  ## CLI
99
144
 
100
145
  ```sh
@@ -120,15 +165,11 @@ Create a `.pr-shepherdrc.yml` in your project root (or any parent directory) to
120
165
  ```yaml
121
166
  iterate:
122
167
  cooldownSeconds: 60 # wait longer after a push before reading CI
123
- fixAttemptsPerThread: 5 # raise before escalating to manual review
124
168
  checks:
125
169
  ciTriggerEvents:
126
170
  - pull_request
127
171
  - pull_request_target
128
172
  - merge_group # add for merge-queue repos
129
- mergeStatus:
130
- blockingReviewerLogins:
131
- - copilot # add other review bots here
132
173
  actions:
133
174
  autoRebase: false # disable for repos that enforce merge commits
134
175
  ```
@@ -138,16 +179,20 @@ See [docs/configuration.md](docs/configuration.md) for all options.
138
179
  ## Requirements
139
180
 
140
181
  - Node.js ≥ 24.0.0
141
- - `gh` CLI authenticated (`gh auth login`)
182
+ - `gh` CLI authenticated (`gh auth login`); `repo` scope is required for private repositories (public repositories may not need it)
142
183
  - `git`
143
184
 
185
+ ## Docs
186
+
187
+ Full reference: [docs/README.md](docs/README.md) — CLI usage, skills, configuration, architecture, actions, debugging, and more.
188
+
144
189
  ## Architecture
145
190
 
146
- See [docs/architecture.md](docs/architecture.md) and [docs/](docs/) for full reference docs.
191
+ See [docs/architecture.md](docs/architecture.md) for the module map and dependency rules.
147
192
 
148
193
  ## Forking
149
194
 
150
- If you want to customize pr-shepherd for your own use or team, see [docs/forking.md](docs/forking.md).
195
+ See [docs/forking.md](docs/forking.md) if you want to customize pr-shepherd for your own use or team.
151
196
 
152
197
  ## License
153
198
 
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Simple filesystem-based cache for shepherd batch reads.
3
+ *
4
+ * Cache entries live in `${TMPDIR}/pr-shepherd-cache/<owner>-<repo>/<pr>/<shape>.json`.
5
+ * TTL defaults to 5 minutes (configurable via PR_SHEPHERD_CACHE_TTL_SECONDS or --cache-ttl).
6
+ *
7
+ * Mutations are never cached — this module is read-path only.
8
+ */
9
+ import { readFile, writeFile, rename, mkdir, stat } from "node:fs/promises";
10
+ import { randomUUID } from "node:crypto";
11
+ import { join, dirname } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { loadConfig } from "../config/load.mjs";
14
+ import { SAFE_SEGMENT } from "../util/path-segment.mjs";
15
+ /**
16
+ * Read a value from the cache. Returns null on miss or expiry.
17
+ */
18
+ export async function cacheGet(key, opts = {}) {
19
+ if (opts.disabled)
20
+ return null;
21
+ const ttl = opts.ttlSeconds ?? ttlFromEnv() ?? loadConfig().cache.ttlSeconds;
22
+ // A TTL of 0 (or negative) means "always expired" — skip the filesystem read entirely.
23
+ if (ttl <= 0)
24
+ return null;
25
+ try {
26
+ const path = resolvePath(key);
27
+ const stats = await stat(path);
28
+ const ageSeconds = (Date.now() - stats.mtimeMs) / 1000;
29
+ if (ageSeconds >= ttl)
30
+ return null;
31
+ const raw = await readFile(path, "utf8");
32
+ return JSON.parse(raw);
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Write a value to the cache. Errors are swallowed — callers can await
40
+ * to know when the write is done, but the write never rejects.
41
+ */
42
+ export async function cacheSet(key, value, opts = {}) {
43
+ if (opts.disabled)
44
+ return;
45
+ try {
46
+ const path = resolvePath(key);
47
+ const tmp = `${path}.${randomUUID()}.tmp`;
48
+ await mkdir(dirname(path), { recursive: true });
49
+ await writeFile(tmp, JSON.stringify(value), "utf8");
50
+ // Atomic rename — prevents a partial read if two processes write concurrently.
51
+ await rename(tmp, path);
52
+ }
53
+ catch {
54
+ // Cache writes are best-effort.
55
+ }
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // Internal helpers
59
+ // ---------------------------------------------------------------------------
60
+ function resolvePath(key) {
61
+ for (const [field, value] of [
62
+ ["owner", key.owner],
63
+ ["repo", key.repo],
64
+ ["shape", key.shape],
65
+ ]) {
66
+ if (!SAFE_SEGMENT.test(value)) {
67
+ throw new Error(`Invalid cache key segment "${field}": ${value}`);
68
+ }
69
+ }
70
+ const base = process.env["PR_SHEPHERD_CACHE_DIR"] ?? join(tmpdir(), "pr-shepherd-cache");
71
+ return join(base, `${key.owner}-${key.repo}`, String(key.pr), `${key.shape}.json`);
72
+ }
73
+ function ttlFromEnv() {
74
+ const raw = process.env["PR_SHEPHERD_CACHE_TTL_SECONDS"];
75
+ if (!raw)
76
+ return undefined;
77
+ const parsed = parseInt(raw, 10);
78
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
79
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Persistent attempt counter for the iterate escalation guard.
3
+ *
4
+ * Tracks how many times each review thread has been dispatched to the fix_code
5
+ * handler without being resolved. Counts are reset automatically when the HEAD
6
+ * commit SHA changes (i.e. a new push landed).
7
+ *
8
+ * State lives in `$TMPDIR/pr-shepherd-cache/<owner>-<repo>/<pr>/fix-attempts.json`.
9
+ */
10
+ import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
11
+ import { randomUUID } from "node:crypto";
12
+ import { join, dirname } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+ import { SAFE_SEGMENT } from "../util/path-segment.mjs";
15
+ // ---------------------------------------------------------------------------
16
+ // Public API
17
+ // ---------------------------------------------------------------------------
18
+ /** Read the current attempt state. Returns null on miss. */
19
+ export async function readFixAttempts(key) {
20
+ try {
21
+ const raw = await readFile(resolvePath(key), "utf8");
22
+ return JSON.parse(raw);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /** Write attempt state (fire-and-forget — never throws). */
29
+ export async function writeFixAttempts(key, state) {
30
+ let tmp;
31
+ try {
32
+ const path = resolvePath(key);
33
+ tmp = `${path}.${randomUUID()}.tmp`;
34
+ await mkdir(dirname(path), { recursive: true });
35
+ await writeFile(tmp, JSON.stringify(state), "utf8");
36
+ await rename(tmp, path);
37
+ tmp = undefined;
38
+ }
39
+ catch {
40
+ // Best-effort.
41
+ }
42
+ finally {
43
+ if (tmp !== undefined) {
44
+ try {
45
+ await unlink(tmp);
46
+ }
47
+ catch {
48
+ // Best-effort cleanup.
49
+ }
50
+ }
51
+ }
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // Helpers
55
+ // ---------------------------------------------------------------------------
56
+ function resolvePath(key) {
57
+ for (const [field, value] of [
58
+ ["owner", key.owner],
59
+ ["repo", key.repo],
60
+ ]) {
61
+ if (!SAFE_SEGMENT.test(value)) {
62
+ throw new Error(`Invalid cache key segment "${field}": ${value}`);
63
+ }
64
+ }
65
+ const base = process.env["PR_SHEPHERD_CACHE_DIR"] ?? join(tmpdir(), "pr-shepherd-cache");
66
+ return join(base, `${key.owner}-${key.repo}`, String(key.pr), "fix-attempts.json");
67
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Classifies check runs into shepherd categories and filters out irrelevant ones.
3
+ *
4
+ * Rules:
5
+ * 1. Skip checks whose workflow event is NOT `pull_request` or `pull_request_target`.
6
+ * Push-triggered, merge-queue, schedule, and workflow-dispatch runs are irrelevant
7
+ * to PR readiness.
8
+ * 2. Drop checks with `conclusion == SKIPPED` or `conclusion == NEUTRAL` from the
9
+ * pass/fail tally. Report them as "skipped" for transparency but don't block on them.
10
+ */
11
+ import { loadConfig } from "../config/load.mjs";
12
+ const RELEVANT_EVENTS = new Set(loadConfig().checks.ciTriggerEvents);
13
+ /**
14
+ * Classify a list of raw check runs into shepherd categories.
15
+ *
16
+ * @param checks Raw check runs from the batch query.
17
+ * @returns Classified checks. "filtered" items were excluded from the tally.
18
+ */
19
+ export function classifyChecks(checks) {
20
+ return checks.map((c) => classify(c));
21
+ }
22
+ function classify(check) {
23
+ // Filter: runs from non-PR events don't count toward PR readiness.
24
+ if (check.event !== null && !RELEVANT_EVENTS.has(check.event)) {
25
+ return { ...check, category: "filtered" };
26
+ }
27
+ const { status, conclusion } = check;
28
+ // Not yet finished.
29
+ if (status !== "COMPLETED") {
30
+ return { ...check, category: "in_progress" };
31
+ }
32
+ // Skipped / neutral — report but don't block.
33
+ if (conclusion === "SKIPPED" || conclusion === "NEUTRAL") {
34
+ return { ...check, category: "skipped" };
35
+ }
36
+ // Success.
37
+ if (conclusion === "SUCCESS") {
38
+ return { ...check, category: "passed" };
39
+ }
40
+ // Everything else (FAILURE, TIMED_OUT, CANCELLED, ACTION_REQUIRED, STARTUP_FAILURE, STALE).
41
+ return { ...check, category: "failing" };
42
+ }
43
+ /** Compute a high-level CI verdict from a list of classified checks. */
44
+ export function getCiVerdict(classified) {
45
+ const relevant = classified.filter((c) => c.category !== "filtered" && c.category !== "skipped");
46
+ const anyInProgress = relevant.some((c) => c.category === "in_progress");
47
+ const anyFailing = relevant.some((c) => c.category === "failing");
48
+ // When there are no relevant checks (e.g. docs-only PR where all checks are filtered/skipped),
49
+ // treat as allPassed rather than blocking — there's nothing to fail.
50
+ const allPassed = !anyInProgress && !anyFailing;
51
+ const filteredNames = classified.filter((c) => c.category === "filtered").map((c) => c.name);
52
+ return { allPassed, anyInProgress, anyFailing, filteredNames };
53
+ }
@@ -0,0 +1,77 @@
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.
7
+ *
8
+ * Shepherd computes and returns triage results, including `failureKind`, for
9
+ * downstream callers or slash-command logic to consume.
10
+ */
11
+ import { execFile as execFileCb } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+ import { loadConfig } from "../config/load.mjs";
14
+ const execFile = promisify(execFileCb);
15
+ const config = loadConfig();
16
+ const TIMEOUT_PATTERNS = config.checks.timeoutPatterns.map((p) => new RegExp(p, "i"));
17
+ const INFRA_PATTERNS = config.checks.infraPatterns.map((p) => new RegExp(p, "i"));
18
+ // ---------------------------------------------------------------------------
19
+ // Public API
20
+ // ---------------------------------------------------------------------------
21
+ /**
22
+ * Fetch logs and triage each failing check.
23
+ *
24
+ * Fetching logs is skipped for checks that have no `runId` (e.g. StatusContext nodes).
25
+ */
26
+ export function triageFailingChecks(failingChecks) {
27
+ return Promise.all(failingChecks.map((c) => triageCheck(c)));
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // Internal
31
+ // ---------------------------------------------------------------------------
32
+ async function triageCheck(check) {
33
+ if (check.runId === null) {
34
+ return { ...check, failureKind: "actionable" };
35
+ }
36
+ const logExcerpt = await fetchFailedLogs(check.runId);
37
+ const failureKind = classifyLogs(check, logExcerpt);
38
+ return {
39
+ ...check,
40
+ failureKind,
41
+ logExcerpt: logExcerpt.slice(-config.checks.logMaxChars) || undefined,
42
+ };
43
+ }
44
+ async function fetchFailedLogs(runId) {
45
+ try {
46
+ const { stdout } = await execFile("gh", ["run", "view", runId, "--log-failed"], {
47
+ maxBuffer: config.execution.triageLogBufferMb * 1024 * 1024,
48
+ });
49
+ // Strip ANSI escape codes.
50
+ // eslint-disable-next-line no-control-regex
51
+ const ansiEscapes = /\u001B\[[0-9;]*m/g;
52
+ return stdout.replace(ansiEscapes, "").split("\n").slice(-config.checks.logMaxLines).join("\n");
53
+ }
54
+ catch {
55
+ return "";
56
+ }
57
+ }
58
+ function classifyLogs(check, logs) {
59
+ // Timed out — check conclusion first, then logs.
60
+ if (check.conclusion === "TIMED_OUT")
61
+ return "timeout";
62
+ if (TIMEOUT_PATTERNS.some((re) => re.test(logs)))
63
+ return "timeout";
64
+ // Infrastructure error — typically CANCELLED with infra markers in logs.
65
+ if (check.conclusion === "CANCELLED" && INFRA_PATTERNS.some((re) => re.test(logs))) {
66
+ return "infrastructure";
67
+ }
68
+ // No logs at all — treat as infrastructure.
69
+ if (!logs.trim())
70
+ 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
+ if (/flaky|timing|race condition|retry/i.test(logs))
74
+ return "flaky";
75
+ // Default: assume actionable.
76
+ return "actionable";
77
+ }