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.
- package/.claude-plugin/plugin.json +8 -2
- package/README.md +126 -83
- package/dist/cache/file-cache.mjs +78 -0
- package/dist/cache/fix-attempts.mjs +67 -0
- package/dist/checks/classify.mjs +53 -0
- package/dist/checks/triage.mjs +77 -0
- package/dist/cli/args.mjs +153 -0
- package/dist/cli.mjs +203 -0
- package/dist/commands/check.mjs +140 -0
- package/dist/commands/iterate.mjs +295 -0
- package/dist/commands/ready-delay.mjs +87 -0
- package/dist/commands/resolve.mjs +64 -0
- package/dist/commands/status.mjs +107 -0
- package/{src/comments/outdated.mts → dist/comments/outdated.mjs} +2 -5
- package/dist/comments/resolve.mjs +113 -0
- package/dist/config/load.mjs +154 -0
- package/dist/github/batch.mjs +208 -0
- package/dist/github/client.mjs +153 -0
- package/{src/github/pagination.mts → dist/github/pagination.mjs} +26 -52
- package/{src/github/queries.mts → dist/github/queries.mjs} +1 -10
- package/{src/index.mts → dist/index.mjs} +3 -5
- package/dist/merge-status/derive.mjs +72 -0
- package/dist/reporters/agent.mjs +41 -0
- package/{src/reporters/json.mts → dist/reporters/json.mjs} +2 -5
- package/dist/reporters/text.mjs +111 -0
- package/dist/types.mjs +2 -0
- package/package.json +6 -6
- package/skills/check/SKILL.md +1 -1
- package/skills/monitor/SKILL.md +9 -5
- package/src/cache/file-cache.mts +0 -101
- package/src/cache/file-cache.test.mts +0 -91
- package/src/cache/fix-attempts.mts +0 -86
- package/src/checks/classify.mts +0 -80
- package/src/checks/classify.test.mts +0 -164
- package/src/checks/triage.mock.test.mts +0 -202
- package/src/checks/triage.mts +0 -88
- package/src/cli.mts +0 -423
- package/src/commands/check.mts +0 -188
- package/src/commands/iterate.mock.test.mts +0 -1111
- package/src/commands/iterate.mts +0 -371
- package/src/commands/ready-delay.mts +0 -117
- package/src/commands/ready-delay.test.mts +0 -116
- package/src/commands/resolve.mts +0 -92
- package/src/commands/status.mts +0 -173
- package/src/comments/resolve.mts +0 -179
- package/src/config/load.mts +0 -240
- package/src/github/batch.mts +0 -351
- package/src/github/client.mts +0 -207
- package/src/github/client.test.mts +0 -19
- package/src/github/pagination.test.mts +0 -140
- package/src/merge-status/derive.mts +0 -74
- package/src/merge-status/derive.test.mts +0 -130
- package/src/reporters/text.mts +0 -140
- package/src/types.mts +0 -309
- /package/{src → dist}/config.json +0 -0
- /package/{src → dist}/github/gql/batch-pr.gql +0 -0
- /package/{src → dist}/github/gql/dismiss-review.gql +0 -0
- /package/{src → dist}/github/gql/minimize-comment.gql +0 -0
- /package/{src → dist}/github/gql/multi-pr-status-paged.gql +0 -0
- /package/{src → dist}/github/gql/multi-pr-status.gql +0 -0
- /package/{src → dist}/github/gql/resolve-thread.gql +0 -0
- /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.
|
|
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": [
|
|
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
|
-
##
|
|
6
|
-
|
|
7
|
-
- **Reduced context** —
|
|
8
|
-
- **Reduced GitHub rate
|
|
9
|
-
- **
|
|
10
|
-
- **No MCP** —
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
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
|
-
##
|
|
14
|
+
## Features
|
|
25
15
|
|
|
26
|
-
|
|
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
|
-
|
|
27
|
+
claude /plugin marketplace add jonathanong/pr-shepherd
|
|
28
|
+
claude /plugin install pr-shepherd
|
|
32
29
|
```
|
|
33
30
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
## Usage
|
|
43
99
|
|
|
44
|
-
|
|
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
|
-
|
|
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 < 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)
|
|
189
|
+
See [docs/architecture.md](docs/architecture.md) for the module map and dependency rules.
|
|
147
190
|
|
|
148
191
|
## Forking
|
|
149
192
|
|
|
150
|
-
|
|
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
|
+
}
|