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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives a shepherd `MergeStatusResult` from raw PR data.
|
|
3
|
+
*
|
|
4
|
+
* `pr.state` is passed through unchanged; `iterate` handles the cancel action
|
|
5
|
+
* for non-OPEN (merged/closed) PRs — this function does not branch on it.
|
|
6
|
+
*
|
|
7
|
+
* Interpretation order for `status` — first match wins:
|
|
8
|
+
* 1. mergeable == CONFLICTING → CONFLICTS
|
|
9
|
+
* 2. mergeStateStatus DIRTY → CONFLICTS (GitHub merge conflicts)
|
|
10
|
+
* 3. copilotReviewInProgress → BLOCKED
|
|
11
|
+
* 4. mergeStateStatus BEHIND → BEHIND
|
|
12
|
+
* 5. mergeStateStatus BLOCKED / HAS_HOOKS → BLOCKED
|
|
13
|
+
* 6. mergeStateStatus UNSTABLE → UNSTABLE
|
|
14
|
+
* 7. isDraft → DRAFT
|
|
15
|
+
* 8. mergeStateStatus UNKNOWN → UNKNOWN
|
|
16
|
+
* 9. mergeStateStatus CLEAN → CLEAN
|
|
17
|
+
*/
|
|
18
|
+
import { loadConfig } from "../config/load.mjs";
|
|
19
|
+
export function deriveMergeStatus(pr) {
|
|
20
|
+
const copilotReviewInProgress = detectCopilotReview(pr);
|
|
21
|
+
let status;
|
|
22
|
+
if (pr.mergeable === "CONFLICTING") {
|
|
23
|
+
status = "CONFLICTS";
|
|
24
|
+
}
|
|
25
|
+
else if (copilotReviewInProgress) {
|
|
26
|
+
status = "BLOCKED";
|
|
27
|
+
}
|
|
28
|
+
else if (pr.mergeStateStatus === "DIRTY") {
|
|
29
|
+
// DIRTY means GitHub detected merge conflicts in the branch.
|
|
30
|
+
status = "CONFLICTS";
|
|
31
|
+
}
|
|
32
|
+
else if (pr.mergeStateStatus === "BEHIND") {
|
|
33
|
+
status = "BEHIND";
|
|
34
|
+
}
|
|
35
|
+
else if (pr.mergeStateStatus === "BLOCKED" || pr.mergeStateStatus === "HAS_HOOKS") {
|
|
36
|
+
status = "BLOCKED";
|
|
37
|
+
}
|
|
38
|
+
else if (pr.mergeStateStatus === "UNSTABLE") {
|
|
39
|
+
status = "UNSTABLE";
|
|
40
|
+
}
|
|
41
|
+
else if (pr.isDraft || pr.mergeStateStatus === "DRAFT") {
|
|
42
|
+
status = "DRAFT";
|
|
43
|
+
}
|
|
44
|
+
else if (pr.mergeStateStatus === "UNKNOWN") {
|
|
45
|
+
status = "UNKNOWN";
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
status = "CLEAN";
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
status,
|
|
52
|
+
state: pr.state,
|
|
53
|
+
isDraft: pr.isDraft,
|
|
54
|
+
mergeable: pr.mergeable,
|
|
55
|
+
reviewDecision: pr.reviewDecision,
|
|
56
|
+
copilotReviewInProgress,
|
|
57
|
+
mergeStateStatus: pr.mergeStateStatus,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Copilot review detection
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
function detectCopilotReview(pr) {
|
|
64
|
+
// A blocking bot review is "in progress" when:
|
|
65
|
+
// 1. Any reviewRequest has a login starting with one of the configured prefixes, OR
|
|
66
|
+
// 2. Any latestReview has such a login AND state == "PENDING"
|
|
67
|
+
const prefixes = loadConfig().mergeStatus.blockingReviewerLogins.map((l) => l.toLowerCase());
|
|
68
|
+
const isBlocking = (login) => prefixes.some((p) => login.toLowerCase().startsWith(p));
|
|
69
|
+
const requested = pr.reviewRequests.some((r) => isBlocking(r.login));
|
|
70
|
+
const pendingReview = pr.latestReviews.some((r) => isBlocking(r.login) && r.state === "PENDING");
|
|
71
|
+
return requested || pendingReview;
|
|
72
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projections for the agent-facing iterate output.
|
|
3
|
+
*
|
|
4
|
+
* These strip fields that are always-false by the time items reach iterate
|
|
5
|
+
* (isResolved, isOutdated, isMinimized, createdAtUnix) and metadata that the
|
|
6
|
+
* monitor prompt never reads (detailsUrl, event, status, conclusion, category,
|
|
7
|
+
* logExcerpt). The original domain types are preserved for check command output.
|
|
8
|
+
*/
|
|
9
|
+
export function toAgentThread(t) {
|
|
10
|
+
return { id: t.id, path: t.path, line: t.line, author: t.author, body: t.body };
|
|
11
|
+
}
|
|
12
|
+
export function toAgentComment(c) {
|
|
13
|
+
return { id: c.id, author: c.author, body: c.body };
|
|
14
|
+
}
|
|
15
|
+
export function toAgentCheck(c) {
|
|
16
|
+
return { name: c.name, runId: c.runId, detailsUrl: c.detailsUrl, failureKind: c.failureKind };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Project and deduplicate checks so the agent makes one `gh run view` call
|
|
20
|
+
* per run (dedup by runId) and skips duplicate external status checks (dedup
|
|
21
|
+
* by name when runId is null).
|
|
22
|
+
*/
|
|
23
|
+
export function toAgentChecks(checks) {
|
|
24
|
+
const seenRunIds = new Set();
|
|
25
|
+
const seenNames = new Set();
|
|
26
|
+
const result = [];
|
|
27
|
+
for (const c of checks) {
|
|
28
|
+
if (c.runId !== null) {
|
|
29
|
+
if (seenRunIds.has(c.runId))
|
|
30
|
+
continue;
|
|
31
|
+
seenRunIds.add(c.runId);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
if (seenNames.has(c.name))
|
|
35
|
+
continue;
|
|
36
|
+
seenNames.add(c.name);
|
|
37
|
+
}
|
|
38
|
+
result.push(toAgentCheck(c));
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
@@ -4,9 +4,6 @@
|
|
|
4
4
|
* Slash commands parse this output to extract IDs, status, and actionable items
|
|
5
5
|
* without string-scraping the human-readable text reporter.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export function formatJson(report: ShepherdReport): string {
|
|
11
|
-
return JSON.stringify(report, null, 2);
|
|
7
|
+
export function formatJson(report) {
|
|
8
|
+
return JSON.stringify(report, null, 2);
|
|
12
9
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable text reporter for shepherd check output.
|
|
3
|
+
*/
|
|
4
|
+
export function formatText(report) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
// Header
|
|
7
|
+
lines.push(`\nPR #${report.pr} — ${report.repo}`);
|
|
8
|
+
lines.push(`Status: ${report.status}`);
|
|
9
|
+
lines.push("");
|
|
10
|
+
// Merge status
|
|
11
|
+
const ms = report.mergeStatus;
|
|
12
|
+
lines.push(`Merge Status: ${ms.status}`);
|
|
13
|
+
lines.push(` mergeStateStatus: ${ms.mergeStateStatus}`);
|
|
14
|
+
lines.push(` mergeable: ${ms.mergeable}`);
|
|
15
|
+
lines.push(` reviewDecision: ${ms.reviewDecision ?? "(none)"}`);
|
|
16
|
+
lines.push(` isDraft: ${ms.isDraft}`);
|
|
17
|
+
lines.push(` copilotReviewInProgress:${ms.copilotReviewInProgress}`);
|
|
18
|
+
lines.push("");
|
|
19
|
+
// CI checks
|
|
20
|
+
const { passing, failing, inProgress, skipped } = report.checks;
|
|
21
|
+
const total = passing.length + failing.length + inProgress.length + skipped.length;
|
|
22
|
+
lines.push(`CI Checks: ${passing.length}/${total} passed`);
|
|
23
|
+
if (failing.length > 0) {
|
|
24
|
+
lines.push(`\nFailed Checks (${failing.length}):`);
|
|
25
|
+
for (const c of failing) {
|
|
26
|
+
const triaged = c;
|
|
27
|
+
const kind = triaged.failureKind ? ` [${triaged.failureKind}]` : "";
|
|
28
|
+
lines.push(` - ${c.name}${kind}: ${c.conclusion ?? c.status}`);
|
|
29
|
+
if (triaged.logExcerpt) {
|
|
30
|
+
lines.push(indent(triaged.logExcerpt.split("\n").slice(-10).join("\n"), " "));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (inProgress.length > 0) {
|
|
35
|
+
lines.push(`\nIn Progress (${inProgress.length}):`);
|
|
36
|
+
for (const c of inProgress) {
|
|
37
|
+
lines.push(` - ${c.name}: ${c.status}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (skipped.length > 0) {
|
|
41
|
+
lines.push(`\nSkipped (${skipped.length}): ${skipped.map((c) => c.name).join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
if (report.checks.filtered.length > 0) {
|
|
44
|
+
lines.push(`\nFiltered (non-PR-trigger) (${report.checks.filtered.length}): ${report.checks.filtered.map((c) => c.name).join(", ")}`);
|
|
45
|
+
if (report.checks.blockedByFilteredCheck) {
|
|
46
|
+
lines.push(" Note: PR is BLOCKED and all filtered checks are non-PR-trigger — one of these filtered checks may be a required status check blocking merge.");
|
|
47
|
+
}
|
|
48
|
+
else if (report.mergeStatus.status === "BLOCKED") {
|
|
49
|
+
lines.push(" Note: one or more of these filtered checks may be a required status check blocking merge.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
lines.push("");
|
|
53
|
+
// Review threads
|
|
54
|
+
const { actionable: actionableThreads, autoResolved, autoResolveErrors } = report.threads;
|
|
55
|
+
if (autoResolved.length > 0) {
|
|
56
|
+
lines.push(`Auto-resolved outdated threads (${autoResolved.length}):`);
|
|
57
|
+
for (const t of autoResolved) {
|
|
58
|
+
lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author})`);
|
|
59
|
+
}
|
|
60
|
+
lines.push("");
|
|
61
|
+
}
|
|
62
|
+
if (autoResolveErrors.length > 0) {
|
|
63
|
+
lines.push(`Auto-resolve errors (${autoResolveErrors.length}):`);
|
|
64
|
+
for (const e of autoResolveErrors) {
|
|
65
|
+
lines.push(` - ${e}`);
|
|
66
|
+
}
|
|
67
|
+
lines.push("");
|
|
68
|
+
}
|
|
69
|
+
if (actionableThreads.length > 0) {
|
|
70
|
+
lines.push(`Actionable Review Threads (${actionableThreads.length}):`);
|
|
71
|
+
for (const t of actionableThreads) {
|
|
72
|
+
const label = t.path ? `${t.path}:${t.line ?? "?"}` : "(general)";
|
|
73
|
+
lines.push(` - threadId=${t.id} ${label} (@${t.author})`);
|
|
74
|
+
lines.push(` ${firstLine(t.body)}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push("");
|
|
77
|
+
}
|
|
78
|
+
// PR comments
|
|
79
|
+
const { actionable: actionableComments } = report.comments;
|
|
80
|
+
if (actionableComments.length > 0) {
|
|
81
|
+
lines.push(`Actionable PR Comments (${actionableComments.length}):`);
|
|
82
|
+
for (const c of actionableComments) {
|
|
83
|
+
lines.push(` - commentId=${c.id} (@${c.author}): ${firstLine(c.body)}`);
|
|
84
|
+
}
|
|
85
|
+
lines.push("");
|
|
86
|
+
}
|
|
87
|
+
// CHANGES_REQUESTED reviews
|
|
88
|
+
if (report.changesRequestedReviews.length > 0) {
|
|
89
|
+
lines.push(`Pending CHANGES_REQUESTED reviews (${report.changesRequestedReviews.length}):`);
|
|
90
|
+
for (const r of report.changesRequestedReviews) {
|
|
91
|
+
lines.push(` - reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
|
|
92
|
+
}
|
|
93
|
+
lines.push("");
|
|
94
|
+
}
|
|
95
|
+
// Summary
|
|
96
|
+
const totalActionable = actionableThreads.length + actionableComments.length + report.changesRequestedReviews.length;
|
|
97
|
+
lines.push(`Summary: ${totalActionable === 0 ? "0 actionable — all threads resolved/minimized" : `${totalActionable} actionable item(s) remaining`}`);
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Helpers
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
function firstLine(text) {
|
|
104
|
+
return (text.split("\n")[0] ?? "").trim().slice(0, 120);
|
|
105
|
+
}
|
|
106
|
+
function indent(text, prefix) {
|
|
107
|
+
return text
|
|
108
|
+
.split("\n")
|
|
109
|
+
.map((l) => prefix + l)
|
|
110
|
+
.join("\n");
|
|
111
|
+
}
|
package/dist/types.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
|
-
"pr-shepherd": "./
|
|
9
|
+
"pr-shepherd": "./dist/index.mjs"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"
|
|
13
|
-
"src/**/*.json",
|
|
14
|
-
"src/**/*.gql",
|
|
12
|
+
"dist/**",
|
|
15
13
|
"skills/**",
|
|
16
14
|
".claude-plugin/**",
|
|
17
15
|
"marketplace.json",
|
|
@@ -33,7 +31,9 @@
|
|
|
33
31
|
"@vitest/coverage-v8": "^4.1.4"
|
|
34
32
|
},
|
|
35
33
|
"scripts": {
|
|
36
|
-
"
|
|
34
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/config.json dist/ && mkdir -p dist/github && cp -r src/github/gql dist/github/",
|
|
35
|
+
"prepare": "npm run build",
|
|
36
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
37
37
|
"typecheck": "tsc --noEmit",
|
|
38
38
|
"lint": "oxlint src/ skills/",
|
|
39
39
|
"format": "oxfmt src/ skills/ docs/ README.md",
|
package/skills/check/SKILL.md
CHANGED
package/skills/monitor/SKILL.md
CHANGED
|
@@ -76,11 +76,15 @@ Parse the `action` field and act:
|
|
|
76
76
|
After fixing manually, rerun /pr-shepherd:monitor <PR> to resume.
|
|
77
77
|
|
|
78
78
|
- `fix_code` → do the following, then stop this iteration (CI needs time):
|
|
79
|
-
1. For each item in `fix.threads
|
|
80
|
-
2.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
1. For each item in `fix.threads` and `fix.comments`: read the referenced file/line and apply the fix (Edit/Write tools).
|
|
80
|
+
2. For each item in `fix.checks`:
|
|
81
|
+
- If `runId` is non-null: fetch the failure log with `gh run view <runId> --log-failed` (dangerouslyDisableSandbox: true), scan the output to identify the failure (e.g. grep for `FAIL` for test failures, `error:` for type/compile errors, lint rule names for lint failures), then read the relevant file and apply the fix (Edit/Write tools).
|
|
82
|
+
- If `runId` is null: the failed check is an external status check that cannot be inspected via run logs. Escalate — tell the user to open `detailsUrl` in the PR checks UI, inspect the failure manually, and rerun `/pr-shepherd:monitor 13` after addressing it. Do not attempt to fix these automatically.
|
|
83
|
+
3. For each item in `fix.changesRequestedReviews`: read the review body and apply the requested changes.
|
|
84
|
+
4. If files were changed, `git add <files> && git commit -m "<appropriate commit message>"`
|
|
85
|
+
5. `git fetch origin && git rebase origin/<BASE_BRANCH> && git push --force-with-lease` (dangerouslyDisableSandbox: true)
|
|
86
|
+
6. `HEAD_SHA=$(git rev-parse HEAD)`
|
|
87
|
+
7. `npx pr-shepherd resolve <PR_NUMBER> --resolve-thread-ids <IDs> --minimize-comment-ids <IDs> --dismiss-review-ids <IDs> --message "address review comments" --require-sha "$HEAD_SHA"` (dangerouslyDisableSandbox: true). Omit any flag whose ID list is empty.
|
|
84
88
|
|
|
85
89
|
````
|
|
86
90
|
|
package/src/cache/file-cache.mts
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
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
|
-
|
|
10
|
-
import { readFile, writeFile, rename, mkdir, stat } 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 { loadConfig } from "../config/load.mts";
|
|
15
|
-
import { SAFE_SEGMENT } from "../util/path-segment.mts";
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Public API
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
export interface CacheOptions {
|
|
22
|
-
ttlSeconds?: number;
|
|
23
|
-
disabled?: boolean;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Read a value from the cache. Returns null on miss or expiry.
|
|
28
|
-
*/
|
|
29
|
-
export async function cacheGet<T>(key: CacheKey, opts: CacheOptions = {}): Promise<T | null> {
|
|
30
|
-
if (opts.disabled) return null;
|
|
31
|
-
|
|
32
|
-
const ttl = opts.ttlSeconds ?? ttlFromEnv() ?? loadConfig().cache.ttlSeconds;
|
|
33
|
-
// A TTL of 0 (or negative) means "always expired" — skip the filesystem read entirely.
|
|
34
|
-
if (ttl <= 0) return null;
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
const path = resolvePath(key);
|
|
38
|
-
const stats = await stat(path);
|
|
39
|
-
const ageSeconds = (Date.now() - stats.mtimeMs) / 1000;
|
|
40
|
-
if (ageSeconds >= ttl) return null;
|
|
41
|
-
|
|
42
|
-
const raw = await readFile(path, "utf8");
|
|
43
|
-
return JSON.parse(raw) as T;
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Write a value to the cache (fire-and-forget — never throws).
|
|
51
|
-
*/
|
|
52
|
-
export async function cacheSet<T>(key: CacheKey, value: T, opts: CacheOptions = {}): Promise<void> {
|
|
53
|
-
if (opts.disabled) return;
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
const path = resolvePath(key);
|
|
57
|
-
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
58
|
-
await mkdir(dirname(path), { recursive: true });
|
|
59
|
-
await writeFile(tmp, JSON.stringify(value), "utf8");
|
|
60
|
-
// Atomic rename — prevents a partial read if two processes write concurrently.
|
|
61
|
-
await rename(tmp, path);
|
|
62
|
-
} catch {
|
|
63
|
-
// Cache writes are best-effort.
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
// Cache key
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
|
|
71
|
-
export interface CacheKey {
|
|
72
|
-
owner: string;
|
|
73
|
-
repo: string;
|
|
74
|
-
pr: number;
|
|
75
|
-
shape: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// Internal helpers
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
function resolvePath(key: CacheKey): string {
|
|
83
|
-
for (const [field, value] of [
|
|
84
|
-
["owner", key.owner],
|
|
85
|
-
["repo", key.repo],
|
|
86
|
-
["shape", key.shape],
|
|
87
|
-
] as const) {
|
|
88
|
-
if (!SAFE_SEGMENT.test(value)) {
|
|
89
|
-
throw new Error(`Invalid cache key segment "${field}": ${value}`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
const base = process.env["PR_SHEPHERD_CACHE_DIR"] ?? join(tmpdir(), "pr-shepherd-cache");
|
|
93
|
-
return join(base, `${key.owner}-${key.repo}`, String(key.pr), `${key.shape}.json`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function ttlFromEnv(): number | undefined {
|
|
97
|
-
const raw = process.env["PR_SHEPHERD_CACHE_TTL_SECONDS"];
|
|
98
|
-
if (!raw) return undefined;
|
|
99
|
-
const parsed = parseInt(raw, 10);
|
|
100
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
101
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { rm } from "node:fs/promises";
|
|
4
|
-
import { cacheGet, cacheSet, type CacheKey } from "./file-cache.mts";
|
|
5
|
-
|
|
6
|
-
// Use a unique test prefix so runs never collide.
|
|
7
|
-
function testKey(shape = "test"): CacheKey {
|
|
8
|
-
return {
|
|
9
|
-
owner: "test-owner",
|
|
10
|
-
repo: "test-repo",
|
|
11
|
-
pr: Math.floor(Math.random() * 900000) + 100000,
|
|
12
|
-
shape,
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
let testCacheDir: string;
|
|
17
|
-
|
|
18
|
-
beforeEach(() => {
|
|
19
|
-
// Point cache at a temp subdir isolated per test run.
|
|
20
|
-
testCacheDir = `${process.env["TMPDIR"] ?? "/tmp"}/shepherd-test-${randomBytes(4).toString("hex")}`;
|
|
21
|
-
process.env["PR_SHEPHERD_CACHE_DIR"] = testCacheDir;
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
afterEach(async () => {
|
|
25
|
-
delete process.env["PR_SHEPHERD_CACHE_DIR"];
|
|
26
|
-
await rm(testCacheDir, { recursive: true, force: true });
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe("cacheGet / cacheSet", () => {
|
|
30
|
-
it("returns null on a cache miss", async () => {
|
|
31
|
-
const result = await cacheGet<string>(testKey());
|
|
32
|
-
expect(result).toBeNull();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("returns the stored value on a cache hit", async () => {
|
|
36
|
-
const key = testKey();
|
|
37
|
-
const value = { foo: "bar", n: 42 };
|
|
38
|
-
await cacheSet(key, value);
|
|
39
|
-
const result = await cacheGet<typeof value>(key);
|
|
40
|
-
expect(result).toEqual(value);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("returns null when the cache entry is expired", async () => {
|
|
44
|
-
const key = testKey();
|
|
45
|
-
await cacheSet(key, { data: "stale" });
|
|
46
|
-
// ttlSeconds=0 means the entry is immediately expired.
|
|
47
|
-
const result = await cacheGet(key, { ttlSeconds: 0 });
|
|
48
|
-
expect(result).toBeNull();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("returns the value within TTL", async () => {
|
|
52
|
-
const key = testKey();
|
|
53
|
-
await cacheSet(key, "fresh");
|
|
54
|
-
const result = await cacheGet<string>(key, { ttlSeconds: 60 });
|
|
55
|
-
expect(result).toBe("fresh");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("returns null when disabled", async () => {
|
|
59
|
-
const key = testKey();
|
|
60
|
-
await cacheSet(key, "should-not-be-returned", { disabled: false });
|
|
61
|
-
const result = await cacheGet(key, { disabled: true });
|
|
62
|
-
expect(result).toBeNull();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("does not write when disabled", async () => {
|
|
66
|
-
const key = testKey();
|
|
67
|
-
await cacheSet(key, "ignored", { disabled: true });
|
|
68
|
-
const result = await cacheGet(key);
|
|
69
|
-
expect(result).toBeNull();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("overwrites an existing cache entry", async () => {
|
|
73
|
-
const key = testKey();
|
|
74
|
-
await cacheSet(key, "first");
|
|
75
|
-
await cacheSet(key, "second");
|
|
76
|
-
const result = await cacheGet<string>(key);
|
|
77
|
-
expect(result).toBe("second");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("handles different shapes as separate entries", async () => {
|
|
81
|
-
const pr = Math.floor(Math.random() * 900000) + 100000;
|
|
82
|
-
const keyA: CacheKey = { owner: "o", repo: "r", pr, shape: "shape-a" };
|
|
83
|
-
const keyB: CacheKey = { owner: "o", repo: "r", pr, shape: "shape-b" };
|
|
84
|
-
|
|
85
|
-
await cacheSet(keyA, "valueA");
|
|
86
|
-
await cacheSet(keyB, "valueB");
|
|
87
|
-
|
|
88
|
-
expect(await cacheGet<string>(keyA)).toBe("valueA");
|
|
89
|
-
expect(await cacheGet<string>(keyB)).toBe("valueB");
|
|
90
|
-
});
|
|
91
|
-
});
|
|
@@ -1,86 +0,0 @@
|
|
|
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
|
-
|
|
11
|
-
import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
12
|
-
import { randomUUID } from "node:crypto";
|
|
13
|
-
import { join, dirname } from "node:path";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
15
|
-
import { SAFE_SEGMENT } from "../util/path-segment.mts";
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Types
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
export interface FixAttemptsState {
|
|
22
|
-
/** HEAD SHA at the time the counts were last written. Reset key. */
|
|
23
|
-
headSha: string;
|
|
24
|
-
/** Map from thread ID → number of fix_code dispatches that included this thread. */
|
|
25
|
-
threadAttempts: Record<string, number>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface CacheKey {
|
|
29
|
-
owner: string;
|
|
30
|
-
repo: string;
|
|
31
|
-
pr: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Public API
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
/** Read the current attempt state. Returns null on miss. */
|
|
39
|
-
export async function readFixAttempts(key: CacheKey): Promise<FixAttemptsState | null> {
|
|
40
|
-
try {
|
|
41
|
-
const raw = await readFile(resolvePath(key), "utf8");
|
|
42
|
-
return JSON.parse(raw) as FixAttemptsState;
|
|
43
|
-
} catch {
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Write attempt state (fire-and-forget — never throws). */
|
|
49
|
-
export async function writeFixAttempts(key: CacheKey, state: FixAttemptsState): Promise<void> {
|
|
50
|
-
let tmp: string | undefined;
|
|
51
|
-
try {
|
|
52
|
-
const path = resolvePath(key);
|
|
53
|
-
tmp = `${path}.${randomUUID()}.tmp`;
|
|
54
|
-
await mkdir(dirname(path), { recursive: true });
|
|
55
|
-
await writeFile(tmp, JSON.stringify(state), "utf8");
|
|
56
|
-
await rename(tmp, path);
|
|
57
|
-
tmp = undefined;
|
|
58
|
-
} catch {
|
|
59
|
-
// Best-effort.
|
|
60
|
-
} finally {
|
|
61
|
-
if (tmp !== undefined) {
|
|
62
|
-
try {
|
|
63
|
-
await unlink(tmp);
|
|
64
|
-
} catch {
|
|
65
|
-
// Best-effort cleanup.
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
// Helpers
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
function resolvePath(key: CacheKey): string {
|
|
76
|
-
for (const [field, value] of [
|
|
77
|
-
["owner", key.owner],
|
|
78
|
-
["repo", key.repo],
|
|
79
|
-
] as const) {
|
|
80
|
-
if (!SAFE_SEGMENT.test(value)) {
|
|
81
|
-
throw new Error(`Invalid cache key segment "${field}": ${value}`);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
const base = process.env["PR_SHEPHERD_CACHE_DIR"] ?? join(tmpdir(), "pr-shepherd-cache");
|
|
85
|
-
return join(base, `${key.owner}-${key.repo}`, String(key.pr), "fix-attempts.json");
|
|
86
|
-
}
|
package/src/checks/classify.mts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
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
|
-
|
|
12
|
-
import type { CheckRun, ClassifiedCheck } from "../types.mts";
|
|
13
|
-
import { loadConfig } from "../config/load.mts";
|
|
14
|
-
|
|
15
|
-
const RELEVANT_EVENTS = new Set(loadConfig().checks.ciTriggerEvents);
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Classify a list of raw check runs into shepherd categories.
|
|
19
|
-
*
|
|
20
|
-
* @param checks Raw check runs from the batch query.
|
|
21
|
-
* @returns Classified checks. "filtered" items were excluded from the tally.
|
|
22
|
-
*/
|
|
23
|
-
export function classifyChecks(checks: CheckRun[]): ClassifiedCheck[] {
|
|
24
|
-
return checks.map((c) => classify(c));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function classify(check: CheckRun): ClassifiedCheck {
|
|
28
|
-
// Filter: runs from non-PR events don't count toward PR readiness.
|
|
29
|
-
if (check.event !== null && !RELEVANT_EVENTS.has(check.event)) {
|
|
30
|
-
return { ...check, category: "filtered" };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const { status, conclusion } = check;
|
|
34
|
-
|
|
35
|
-
// Not yet finished.
|
|
36
|
-
if (status !== "COMPLETED") {
|
|
37
|
-
return { ...check, category: "in_progress" };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Skipped / neutral — report but don't block.
|
|
41
|
-
if (conclusion === "SKIPPED" || conclusion === "NEUTRAL") {
|
|
42
|
-
return { ...check, category: "skipped" };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Success.
|
|
46
|
-
if (conclusion === "SUCCESS") {
|
|
47
|
-
return { ...check, category: "passed" };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Everything else (FAILURE, TIMED_OUT, CANCELLED, ACTION_REQUIRED, STARTUP_FAILURE, STALE).
|
|
51
|
-
return { ...check, category: "failing" };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
// Aggregate verdict helpers
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
export interface CiVerdict {
|
|
59
|
-
/** True when all relevant (non-filtered, non-skipped) checks passed. */
|
|
60
|
-
allPassed: boolean;
|
|
61
|
-
/** True when at least one check is still running/queued. */
|
|
62
|
-
anyInProgress: boolean;
|
|
63
|
-
/** True when at least one check failed. */
|
|
64
|
-
anyFailing: boolean;
|
|
65
|
-
/** Names of checks that were filtered out (triggered by non-PR events). */
|
|
66
|
-
filteredNames: string[];
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Compute a high-level CI verdict from a list of classified checks. */
|
|
70
|
-
export function getCiVerdict(classified: ClassifiedCheck[]): CiVerdict {
|
|
71
|
-
const relevant = classified.filter((c) => c.category !== "filtered" && c.category !== "skipped");
|
|
72
|
-
const anyInProgress = relevant.some((c) => c.category === "in_progress");
|
|
73
|
-
const anyFailing = relevant.some((c) => c.category === "failing");
|
|
74
|
-
// When there are no relevant checks (e.g. docs-only PR where all checks are filtered/skipped),
|
|
75
|
-
// treat as allPassed rather than blocking — there's nothing to fail.
|
|
76
|
-
const allPassed = !anyInProgress && !anyFailing;
|
|
77
|
-
const filteredNames = classified.filter((c) => c.category === "filtered").map((c) => c.name);
|
|
78
|
-
|
|
79
|
-
return { allPassed, anyInProgress, anyFailing, filteredNames };
|
|
80
|
-
}
|