pr-shepherd 0.2.0 → 0.3.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 (62) hide show
  1. package/.claude-plugin/plugin.json +8 -2
  2. package/README.md +126 -83
  3. package/dist/cache/file-cache.mjs +78 -0
  4. package/dist/cache/fix-attempts.mjs +67 -0
  5. package/dist/checks/classify.mjs +53 -0
  6. package/dist/checks/triage.mjs +77 -0
  7. package/dist/cli/args.mjs +153 -0
  8. package/dist/cli.mjs +203 -0
  9. package/dist/commands/check.mjs +140 -0
  10. package/dist/commands/iterate.mjs +295 -0
  11. package/dist/commands/ready-delay.mjs +87 -0
  12. package/dist/commands/resolve.mjs +64 -0
  13. package/dist/commands/status.mjs +107 -0
  14. package/{src/comments/outdated.mts → dist/comments/outdated.mjs} +2 -5
  15. package/dist/comments/resolve.mjs +113 -0
  16. package/dist/config/load.mjs +154 -0
  17. package/dist/github/batch.mjs +208 -0
  18. package/dist/github/client.mjs +153 -0
  19. package/{src/github/pagination.mts → dist/github/pagination.mjs} +26 -52
  20. package/{src/github/queries.mts → dist/github/queries.mjs} +1 -10
  21. package/{src/index.mts → dist/index.mjs} +3 -5
  22. package/dist/merge-status/derive.mjs +72 -0
  23. package/dist/reporters/agent.mjs +41 -0
  24. package/{src/reporters/json.mts → dist/reporters/json.mjs} +2 -5
  25. package/dist/reporters/text.mjs +111 -0
  26. package/dist/types.mjs +2 -0
  27. package/package.json +6 -6
  28. package/skills/check/SKILL.md +1 -1
  29. package/skills/monitor/SKILL.md +9 -5
  30. package/src/cache/file-cache.mts +0 -101
  31. package/src/cache/file-cache.test.mts +0 -91
  32. package/src/cache/fix-attempts.mts +0 -86
  33. package/src/checks/classify.mts +0 -80
  34. package/src/checks/classify.test.mts +0 -164
  35. package/src/checks/triage.mock.test.mts +0 -202
  36. package/src/checks/triage.mts +0 -88
  37. package/src/cli.mts +0 -423
  38. package/src/commands/check.mts +0 -188
  39. package/src/commands/iterate.mock.test.mts +0 -1111
  40. package/src/commands/iterate.mts +0 -371
  41. package/src/commands/ready-delay.mts +0 -117
  42. package/src/commands/ready-delay.test.mts +0 -116
  43. package/src/commands/resolve.mts +0 -92
  44. package/src/commands/status.mts +0 -173
  45. package/src/comments/resolve.mts +0 -179
  46. package/src/config/load.mts +0 -240
  47. package/src/github/batch.mts +0 -351
  48. package/src/github/client.mts +0 -207
  49. package/src/github/client.test.mts +0 -19
  50. package/src/github/pagination.test.mts +0 -140
  51. package/src/merge-status/derive.mts +0 -74
  52. package/src/merge-status/derive.test.mts +0 -130
  53. package/src/reporters/text.mts +0 -140
  54. package/src/types.mts +0 -309
  55. /package/{src → dist}/config.json +0 -0
  56. /package/{src → dist}/github/gql/batch-pr.gql +0 -0
  57. /package/{src → dist}/github/gql/dismiss-review.gql +0 -0
  58. /package/{src → dist}/github/gql/minimize-comment.gql +0 -0
  59. /package/{src → dist}/github/gql/multi-pr-status-paged.gql +0 -0
  60. /package/{src → dist}/github/gql/multi-pr-status.gql +0 -0
  61. /package/{src → dist}/github/gql/resolve-thread.gql +0 -0
  62. /package/{src/util/path-segment.mts → dist/util/path-segment.mjs} +0 -0
@@ -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.3.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,98 +2,141 @@
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
+ See [Usage](#usage) below.
32
+
33
+ ### Without the plugin — custom slash command
34
+
35
+ If you don't want the full plugin, create a project-local (or user-scope)
36
+ slash command that wraps the CLI directly. This still requires `pr-shepherd`
37
+ to be installed in the repository first (`npm install pr-shepherd`), so that
38
+ `npx pr-shepherd ...` runs without prompting to install the package.
39
+
40
+ 1. **Create the command file:**
41
+ - Project-scope: `.claude/commands/pr-check.md`
42
+ - User-scope: `~/.claude/commands/pr-check.md`
43
+
44
+ 2. **Paste this as the file contents:**
45
+
46
+ ````markdown
47
+ ---
48
+ description: "Check GitHub CI status and review comments for the current PR"
49
+ argument-hint: "[PR number or URL ...]"
50
+ allowed-tools: ["Bash", "Read", "Grep"]
51
+ ---
52
+
53
+ # PR Status Check
54
+
55
+ ## Arguments: $ARGUMENTS
56
+
57
+ ## Resolve PR number(s)
58
+
59
+ 1. If `$ARGUMENTS` contains PR numbers or GitHub PR URLs, extract the number(s).
60
+ 2. Otherwise, infer: `gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number --jq '.[0].number'`
61
+ 3. If no PR found, report an error and stop.
62
+
63
+ ## Run the check
64
+
65
+ ```bash
66
+ npx pr-shepherd check <PR_NUMBER> --format=json
67
+ ```
68
+
69
+ Parse the JSON and report:
70
+
71
+ - **Merge status** (`report.mergeStatus.status`): CLEAN | BEHIND | CONFLICTS | BLOCKED | UNSTABLE | DRAFT | UNKNOWN
72
+ - **CI check results** (`report.checks`): passing count, failing names, in-progress names
73
+ - **Unresolved review comments** (`report.threads.actionable` + `report.comments.actionable`): count + details
74
+ ````
75
+
76
+ 3. **Use it in Claude Code:**
77
+
78
+ ```
79
+ /pr-check
80
+ /pr-check 42
81
+ ```
82
+
83
+ For `monitor` and `resolve` custom commands, do **not** copy the
84
+ [`skills/`](skills/) files directly — those contain skill/plugin-specific
85
+ frontmatter that is not valid for `.claude/commands/` files. Instead, create
86
+ `.claude/commands/pr-monitor.md` and/or `.claude/commands/pr-resolve.md`
87
+ using the same command-file structure as the `pr-check` example above, with
88
+ the CLI invocation changed to `npx pr-shepherd iterate ...` or
89
+ `npx pr-shepherd resolve ...`. To drive the CLI without Claude at all, see
90
+ [docs/usage.md](docs/usage.md).
91
+
92
+ ### As a global CLI
35
93
 
36
94
  ```bash
37
- # Install from marketplace
38
- claude /plugin marketplace add jonathanong/pr-shepherd
39
- claude /plugin install pr-shepherd
95
+ npm install -g pr-shepherd
40
96
  ```
41
97
 
42
- Then use:
98
+ ## Usage
43
99
 
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
100
+ ### Monitor a PR
47
101
 
48
- ## Workflow
102
+ Creates a cron loop that fires every 4 minutes, checks CI and review
103
+ comments, fixes issues, and marks the PR ready for review when clean. The
104
+ loop cancels automatically when the PR is merged or closed.
49
105
 
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
96
106
  ```
107
+ /pr-shepherd:monitor # infer PR from current branch
108
+ /pr-shepherd:monitor 42
109
+ /pr-shepherd:monitor 42 every 8m
110
+ /pr-shepherd:monitor 42 --ready-delay 15m
111
+ ```
112
+
113
+ ### Check a PR
114
+
115
+ One-shot status snapshot — merge state, CI results, and unresolved comments.
116
+ Accepts multiple PR numbers.
117
+
118
+ ```
119
+ /pr-shepherd:check # infer from branch
120
+ /pr-shepherd:check 42
121
+ /pr-shepherd:check 41 42 43
122
+ ```
123
+
124
+ ### Resolve review comments
125
+
126
+ Fetches all actionable threads and comments, triages them, applies fixes,
127
+ pushes, then resolves/minimizes/dismisses via `--require-sha` (waits until
128
+ GitHub has seen the push before resolving).
129
+
130
+ ```
131
+ /pr-shepherd:resolve # infer from branch
132
+ /pr-shepherd:resolve 42
133
+ ```
134
+
135
+ See [docs/skills.md](docs/skills.md) for full argument reference.
136
+
137
+ ## Workflow
138
+
139
+ 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.
97
140
 
98
141
  ## CLI
99
142
 
@@ -120,15 +163,11 @@ Create a `.pr-shepherdrc.yml` in your project root (or any parent directory) to
120
163
  ```yaml
121
164
  iterate:
122
165
  cooldownSeconds: 60 # wait longer after a push before reading CI
123
- fixAttemptsPerThread: 5 # raise before escalating to manual review
124
166
  checks:
125
167
  ciTriggerEvents:
126
168
  - pull_request
127
169
  - pull_request_target
128
170
  - merge_group # add for merge-queue repos
129
- mergeStatus:
130
- blockingReviewerLogins:
131
- - copilot # add other review bots here
132
171
  actions:
133
172
  autoRebase: false # disable for repos that enforce merge commits
134
173
  ```
@@ -138,16 +177,20 @@ See [docs/configuration.md](docs/configuration.md) for all options.
138
177
  ## Requirements
139
178
 
140
179
  - Node.js ≥ 24.0.0
141
- - `gh` CLI authenticated (`gh auth login`)
180
+ - `gh` CLI authenticated (`gh auth login`); `repo` scope is required for private repositories (public repositories may not need it)
142
181
  - `git`
143
182
 
183
+ ## Docs
184
+
185
+ Full reference: [docs/README.md](docs/README.md) — CLI usage, skills, configuration, architecture, actions, debugging, and more.
186
+
144
187
  ## Architecture
145
188
 
146
- See [docs/architecture.md](docs/architecture.md) and [docs/](docs/) for full reference docs.
189
+ See [docs/architecture.md](docs/architecture.md) for the module map and dependency rules.
147
190
 
148
191
  ## Forking
149
192
 
150
- If you want to customize pr-shepherd for your own use or team, see [docs/forking.md](docs/forking.md).
193
+ See [docs/forking.md](docs/forking.md) if you want to customize pr-shepherd for your own use or team.
151
194
 
152
195
  ## License
153
196
 
@@ -0,0 +1,78 @@
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 (fire-and-forget — never throws).
40
+ */
41
+ export async function cacheSet(key, value, opts = {}) {
42
+ if (opts.disabled)
43
+ return;
44
+ try {
45
+ const path = resolvePath(key);
46
+ const tmp = `${path}.${randomUUID()}.tmp`;
47
+ await mkdir(dirname(path), { recursive: true });
48
+ await writeFile(tmp, JSON.stringify(value), "utf8");
49
+ // Atomic rename — prevents a partial read if two processes write concurrently.
50
+ await rename(tmp, path);
51
+ }
52
+ catch {
53
+ // Cache writes are best-effort.
54
+ }
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Internal helpers
58
+ // ---------------------------------------------------------------------------
59
+ function resolvePath(key) {
60
+ for (const [field, value] of [
61
+ ["owner", key.owner],
62
+ ["repo", key.repo],
63
+ ["shape", key.shape],
64
+ ]) {
65
+ if (!SAFE_SEGMENT.test(value)) {
66
+ throw new Error(`Invalid cache key segment "${field}": ${value}`);
67
+ }
68
+ }
69
+ const base = process.env["PR_SHEPHERD_CACHE_DIR"] ?? join(tmpdir(), "pr-shepherd-cache");
70
+ return join(base, `${key.owner}-${key.repo}`, String(key.pr), `${key.shape}.json`);
71
+ }
72
+ function ttlFromEnv() {
73
+ const raw = process.env["PR_SHEPHERD_CACHE_TTL_SECONDS"];
74
+ if (!raw)
75
+ return undefined;
76
+ const parsed = parseInt(raw, 10);
77
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
78
+ }
@@ -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
+ }