pr-shepherd 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +8 -2
  3. package/README.md +128 -83
  4. package/bin/cache/file-cache.mjs +79 -0
  5. package/bin/cache/fix-attempts.mjs +67 -0
  6. package/bin/checks/classify.mjs +53 -0
  7. package/bin/checks/triage.mjs +77 -0
  8. package/bin/cli/args.mjs +173 -0
  9. package/bin/cli.mjs +204 -0
  10. package/bin/commands/check.mjs +140 -0
  11. package/bin/commands/iterate.mjs +301 -0
  12. package/bin/commands/ready-delay.mjs +87 -0
  13. package/bin/commands/resolve.mjs +64 -0
  14. package/bin/commands/status.mjs +107 -0
  15. package/{src/comments/outdated.mts → bin/comments/outdated.mjs} +2 -5
  16. package/bin/comments/resolve.mjs +111 -0
  17. package/bin/config/load.mjs +158 -0
  18. package/bin/github/batch.mjs +208 -0
  19. package/bin/github/client.mjs +152 -0
  20. package/{src/github/pagination.mts → bin/github/pagination.mjs} +26 -52
  21. package/{src/github/queries.mts → bin/github/queries.mjs} +1 -10
  22. package/{src/index.mts → bin/index.mjs} +3 -5
  23. package/bin/merge-status/derive.mjs +72 -0
  24. package/bin/pr-shepherd +2 -0
  25. package/bin/reporters/agent.mjs +41 -0
  26. package/{src/reporters/json.mts → bin/reporters/json.mjs} +2 -5
  27. package/bin/reporters/text.mjs +111 -0
  28. package/bin/types.mjs +2 -0
  29. package/package.json +9 -9
  30. package/skills/check/SKILL.md +12 -14
  31. package/skills/monitor/SKILL.md +9 -5
  32. package/src/cache/file-cache.mts +0 -101
  33. package/src/cache/file-cache.test.mts +0 -91
  34. package/src/cache/fix-attempts.mts +0 -86
  35. package/src/checks/classify.mts +0 -80
  36. package/src/checks/classify.test.mts +0 -164
  37. package/src/checks/triage.mock.test.mts +0 -202
  38. package/src/checks/triage.mts +0 -88
  39. package/src/cli.mts +0 -423
  40. package/src/commands/check.mts +0 -188
  41. package/src/commands/iterate.mock.test.mts +0 -1111
  42. package/src/commands/iterate.mts +0 -371
  43. package/src/commands/ready-delay.mts +0 -117
  44. package/src/commands/ready-delay.test.mts +0 -116
  45. package/src/commands/resolve.mts +0 -92
  46. package/src/commands/status.mts +0 -173
  47. package/src/comments/resolve.mts +0 -179
  48. package/src/config/load.mts +0 -240
  49. package/src/github/batch.mts +0 -351
  50. package/src/github/client.mts +0 -207
  51. package/src/github/client.test.mts +0 -19
  52. package/src/github/pagination.test.mts +0 -140
  53. package/src/merge-status/derive.mts +0 -74
  54. package/src/merge-status/derive.test.mts +0 -130
  55. package/src/reporters/text.mts +0 -140
  56. package/src/types.mts +0 -309
  57. /package/{src → bin}/config.json +0 -0
  58. /package/{src → bin}/github/gql/batch-pr.gql +0 -0
  59. /package/{src → bin}/github/gql/dismiss-review.gql +0 -0
  60. /package/{src → bin}/github/gql/minimize-comment.gql +0 -0
  61. /package/{src → bin}/github/gql/multi-pr-status-paged.gql +0 -0
  62. /package/{src → bin}/github/gql/multi-pr-status.gql +0 -0
  63. /package/{src → bin}/github/gql/resolve-thread.gql +0 -0
  64. /package/{src/util/path-segment.mts → bin/util/path-segment.mjs} +0 -0
@@ -9,19 +9,6 @@
9
9
  * - Forward (`after` + `first`) — used by check contexts.
10
10
  * - Backward (`before` + `last`) — used by reviewThreads (default GitHub order).
11
11
  */
12
-
13
- export interface PageInfo {
14
- hasNextPage?: boolean;
15
- hasPreviousPage?: boolean;
16
- endCursor?: string | null;
17
- startCursor?: string | null;
18
- }
19
-
20
- export interface Connection<T> {
21
- pageInfo: PageInfo;
22
- nodes: T[];
23
- }
24
-
25
12
  /**
26
13
  * Paginate forward through a GraphQL connection (`first` / `after` cursors).
27
14
  *
@@ -31,26 +18,19 @@ export interface Connection<T> {
31
18
  * `endCursor` of an already-fetched page to fetch only
32
19
  * the pages *after* it, avoiding a duplicate re-fetch.
33
20
  */
34
- export async function paginateForward<T>(
35
- fetchFn: (cursor: string | null) => Promise<Connection<T>>,
36
- initialCursor?: string | null,
37
- ): Promise<T[]> {
38
- const all: T[] = [];
39
- let cursor: string | null = initialCursor ?? null;
40
-
41
- for (;;) {
42
- // eslint-disable-next-line no-await-in-loop
43
- const conn = await fetchFn(cursor);
44
-
45
- all.push(...conn.nodes);
46
-
47
- if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) break;
48
- cursor = conn.pageInfo.endCursor;
49
- }
50
-
51
- return all;
21
+ export async function paginateForward(fetchFn, initialCursor) {
22
+ const all = [];
23
+ let cursor = initialCursor ?? null;
24
+ for (;;) {
25
+ // eslint-disable-next-line no-await-in-loop
26
+ const conn = await fetchFn(cursor);
27
+ all.push(...conn.nodes);
28
+ if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor)
29
+ break;
30
+ cursor = conn.pageInfo.endCursor;
31
+ }
32
+ return all;
52
33
  }
53
-
54
34
  /**
55
35
  * Paginate backward through a GraphQL connection (`last` / `before` cursors).
56
36
  *
@@ -63,24 +43,18 @@ export async function paginateForward<T>(
63
43
  * `startCursor` of an already-fetched page to avoid
64
44
  * re-fetching it (fetch only the pages *before* it).
65
45
  */
66
- export async function paginateBackward<T>(
67
- fetchFn: (cursor: string | null) => Promise<Connection<T>>,
68
- initialCursor?: string | null,
69
- ): Promise<T[]> {
70
- const all: T[] = [];
71
- let cursor: string | null = initialCursor ?? null;
72
-
73
- for (;;) {
74
- // eslint-disable-next-line no-await-in-loop
75
- const conn = await fetchFn(cursor);
76
-
77
- // Backward pagination returns items oldest-first within each page but pages
78
- // go from newest-to-oldest. Prepend each page so final array is oldest-first.
79
- all.unshift(...conn.nodes);
80
-
81
- if (!conn.pageInfo.hasPreviousPage || !conn.pageInfo.startCursor) break;
82
- cursor = conn.pageInfo.startCursor;
83
- }
84
-
85
- return all;
46
+ export async function paginateBackward(fetchFn, initialCursor) {
47
+ const all = [];
48
+ let cursor = initialCursor ?? null;
49
+ for (;;) {
50
+ // eslint-disable-next-line no-await-in-loop
51
+ const conn = await fetchFn(cursor);
52
+ // Backward pagination returns items oldest-first within each page but pages
53
+ // go from newest-to-oldest. Prepend each page so final array is oldest-first.
54
+ all.unshift(...conn.nodes);
55
+ if (!conn.pageInfo.hasPreviousPage || !conn.pageInfo.startCursor)
56
+ break;
57
+ cursor = conn.pageInfo.startCursor;
58
+ }
59
+ return all;
86
60
  }
@@ -4,27 +4,18 @@
4
4
  * Query strings live in src/github/gql/*.gql.
5
5
  * Never inline raw GraphQL strings in .ts source files.
6
6
  */
7
-
8
7
  import { readFileSync } from "node:fs";
9
8
  import { join } from "node:path";
10
-
11
- const gql = (name: string): string =>
12
- readFileSync(join((import.meta as { dirname: string }).dirname, "gql", name), "utf8");
13
-
9
+ const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8");
14
10
  /** The primary batch query that fetches CI + comments + merge status in one round-trip. */
15
11
  export const BATCH_PR_QUERY = gql("batch-pr.gql");
16
-
17
12
  /** Resolve a single review thread. */
18
13
  export const RESOLVE_THREAD_MUTATION = gql("resolve-thread.gql");
19
-
20
14
  /** Minimize a PR comment (IssueComment). */
21
15
  export const MINIMIZE_COMMENT_MUTATION = gql("minimize-comment.gql");
22
-
23
16
  /** Dismiss a pull request review. */
24
17
  export const DISMISS_REVIEW_MUTATION = gql("dismiss-review.gql");
25
-
26
18
  /** Multi-PR status query for `shepherd status PR1 PR2 …`. */
27
19
  export const MULTI_PR_STATUS_QUERY = gql("multi-pr-status.gql");
28
-
29
20
  /** Paginated version — used when reviewThreads is truncated (totalCount > 100). */
30
21
  export const MULTI_PR_STATUS_QUERY_WITH_CURSOR = gql("multi-pr-status-paged.gql");
@@ -8,10 +8,8 @@
8
8
  * pr-shepherd iterate [PR]
9
9
  * pr-shepherd status PR1 [PR2 …]
10
10
  */
11
-
12
- import { main } from "./cli.mts";
13
-
11
+ import { main } from "./cli.mjs";
14
12
  main(process.argv).catch((err) => {
15
- process.stderr.write(`pr-shepherd error: ${err instanceof Error ? err.message : String(err)}\n`);
16
- process.exit(1);
13
+ process.stderr.write(`pr-shepherd error: ${err instanceof Error ? err.message : String(err)}\n`);
14
+ process.exit(1);
17
15
  });
@@ -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,2 @@
1
+ #!/usr/bin/env node
2
+ import("./index.mjs")
@@ -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/bin/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.4.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": "bin/index.mjs"
10
10
  },
11
11
  "files": [
12
- "src/**/*.mts",
13
- "src/**/*.json",
14
- "src/**/*.gql",
12
+ "bin/**",
15
13
  "skills/**",
16
14
  ".claude-plugin/**",
17
15
  "marketplace.json",
@@ -19,21 +17,23 @@
19
17
  "LICENSE"
20
18
  ],
21
19
  "engines": {
22
- "node": ">=24.0.0"
20
+ "node": ">=22.0.0"
23
21
  },
24
22
  "dependencies": {
25
23
  "yaml": "^2.7.0"
26
24
  },
27
25
  "devDependencies": {
28
26
  "@types/node": "^25.6.0",
29
- "oxfmt": "latest",
30
- "oxlint": "latest",
27
+ "oxfmt": "^0.45.0",
28
+ "oxlint": "^1.60.0",
31
29
  "typescript": "^6.0.3",
32
30
  "vitest": "^4.1.4",
33
31
  "@vitest/coverage-v8": "^4.1.4"
34
32
  },
35
33
  "scripts": {
36
- "prepublishOnly": "npm run typecheck && npm test",
34
+ "build": "node scripts/build.mjs",
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
@@ -40,24 +40,22 @@ Parse the JSON output and report all three:
40
40
 
41
41
  ## Rebase policy
42
42
 
43
- ```bash
44
- BASE_BRANCH=$(gh pr view <N> --json baseRefName --jq '.baseRefName')
45
- ```
43
+ The CLI already determines whether a rebase is warranted. Read `report.mergeStatus.status` directly:
46
44
 
47
- Rebase (`git fetch origin && git rebase origin/<BASE_BRANCH> && git push --force-with-lease`) when:
45
+ - `CONFLICTS` a rebase is required to resolve the merge conflict before the PR can land.
46
+ - `BEHIND` — a rebase may be appropriate; a `flaky` failure while `BEHIND` is the canonical rebase signal. If all checks pass but the PR is `BEHIND`, a rebase is optional.
47
+ - Any other status — no rebase needed.
48
48
 
49
- - Merge conflicts with main (`report.mergeStatus.status == 'CONFLICTS'`), OR
50
- - About to push commits and branch is behind main, OR
51
- - `failureKind == 'flaky'` AND branch is behind main
52
-
53
- Do NOT rebase when nothing to push, no conflicts, and no flaky failures.
49
+ Do not re-derive these conditions from raw branch state. For automated monitoring that acts on these signals, use `/pr-shepherd:monitor` — it handles rebase decisions end-to-end.
54
50
 
55
51
  ## CI budget policy
56
52
 
57
- - **actionable**: Summarize errors. Fix in next step.
58
- - **infrastructure**: Re-run: `gh run rerun <runId> --failed`
59
- - **timeout**: Re-run: `gh run rerun <runId> --failed`
60
- - **flaky**: Do NOT cancel. Rebase if behind main.
53
+ Each entry in `report.checks` carries a `failureKind` field. Read it directly rather than re-classifying failures:
54
+
55
+ - `actionable` — the failure is code-level and needs a fix.
56
+ - `infrastructure` transient infra problem; re-run with `gh run rerun <runId> --failed`.
57
+ - `timeout` — job exceeded the time limit; re-run with `gh run rerun <runId> --failed`.
58
+ - `flaky` — known-flaky test; do NOT cancel. Rebase first if `mergeStatus.status` is `BEHIND`.
61
59
 
62
60
  ## Never declare ready to merge
63
61
 
@@ -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 <PR_NUMBER>` 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
- }