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
@@ -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
- import type { ShepherdReport } from "../types.mts";
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
@@ -0,0 +1,2 @@
1
+ /** Shared type definitions for the shepherd CLI. */
2
+ export {};
package/package.json CHANGED
@@ -1,17 +1,15 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.2.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": "./src/index.mts"
9
+ "pr-shepherd": "./dist/index.mjs"
10
10
  },
11
11
  "files": [
12
- "src/**/*.mts",
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
- "prepublishOnly": "npm run typecheck && npm test",
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",
@@ -3,7 +3,7 @@ name: check
3
3
  description: "Check GitHub CI status and review comments for the current PR"
4
4
  argument-hint: "[PR number or URL ...]"
5
5
  user-invocable: true
6
- allowed-tools: ["Bash", "Read", "Grep"]
6
+ allowed-tools: ["Bash"]
7
7
  ---
8
8
 
9
9
  # pr-shepherd check — PR Status
@@ -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`, `fix.comments`, `fix.checks`, and `fix.changesRequestedReviews`: read the referenced file/line and apply the fix (Edit/Write tools).
80
- 2. If files were changed, `git add <files> && git commit -m "<appropriate commit message>"`
81
- 3. `git fetch origin && git rebase origin/<BASE_BRANCH> && git push --force-with-lease` (dangerouslyDisableSandbox: true)
82
- 4. `HEAD_SHA=$(git rev-parse HEAD)`
83
- 5. `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.
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
 
@@ -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
- }
@@ -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
- }