pr-shepherd 0.7.0 → 0.8.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 (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -302
  3. package/bin/checks/classify.mjs +5 -4
  4. package/bin/checks/triage.mjs +76 -62
  5. package/bin/cli/args.mjs +29 -61
  6. package/bin/cli/exit-codes.mjs +39 -0
  7. package/bin/cli/fix-formatter.mjs +76 -0
  8. package/bin/cli/formatters.mjs +108 -0
  9. package/bin/cli/handlers.mjs +138 -0
  10. package/bin/cli/iterate-formatter.mjs +78 -0
  11. package/bin/cli-parser.iterate-fixtures.mjs +65 -0
  12. package/bin/cli-parser.mjs +110 -0
  13. package/bin/commands/check-status.mjs +35 -0
  14. package/bin/commands/check.mjs +14 -61
  15. package/bin/commands/commit-suggestion.mjs +159 -0
  16. package/bin/commands/iterate/classify.mjs +77 -0
  17. package/bin/commands/iterate/escalate.mjs +124 -0
  18. package/bin/commands/iterate/fix-code.mjs +97 -0
  19. package/bin/commands/iterate/helpers.mjs +103 -0
  20. package/bin/commands/iterate/index.mjs +122 -0
  21. package/bin/commands/iterate/render.mjs +119 -0
  22. package/bin/commands/iterate/stall.mjs +65 -0
  23. package/bin/commands/iterate/steps.mjs +31 -0
  24. package/bin/commands/iterate.mjs +2 -628
  25. package/bin/commands/monitor.mjs +78 -0
  26. package/bin/commands/ready-delay.mjs +3 -4
  27. package/bin/commands/resolve-instructions.mjs +39 -0
  28. package/bin/commands/resolve.mjs +34 -3
  29. package/bin/commands/status.mjs +7 -0
  30. package/bin/comments/resolve.mjs +1 -1
  31. package/bin/config/load.mjs +17 -113
  32. package/bin/config.json +10 -22
  33. package/bin/github/batch-parsers.mjs +140 -0
  34. package/bin/github/batch-raw-types.mjs +2 -0
  35. package/bin/github/batch.mjs +34 -129
  36. package/bin/github/client.mjs +47 -9
  37. package/bin/github/gql/batch-pr.gql +20 -0
  38. package/bin/github/http.mjs +32 -30
  39. package/bin/index.mjs +15 -2
  40. package/bin/merge-status/derive.mjs +11 -11
  41. package/bin/reporters/agent.mjs +13 -4
  42. package/bin/reporters/check-instructions.mjs +65 -0
  43. package/bin/reporters/json.mjs +3 -2
  44. package/bin/reporters/text.mjs +108 -61
  45. package/bin/{cache → state}/fix-attempts.mjs +3 -3
  46. package/bin/state/iterate-stall.mjs +74 -0
  47. package/bin/suggestions/parse.mjs +119 -0
  48. package/bin/suggestions/patch.mjs +52 -0
  49. package/bin/types/github.mjs +2 -0
  50. package/bin/types/iterate.mjs +2 -0
  51. package/bin/types/report.mjs +2 -0
  52. package/bin/types.mjs +3 -1
  53. package/package.json +3 -3
  54. package/plugin/skills/check/SKILL.md +15 -48
  55. package/plugin/skills/monitor/SKILL.md +11 -64
  56. package/plugin/skills/resolve/SKILL.md +10 -76
  57. package/bin/cache/file-cache.mjs +0 -79
  58. package/bin/cli.mjs +0 -286
@@ -1,101 +1,154 @@
1
- /**
2
- * Human-readable text reporter for shepherd check output.
3
- */
1
+ import { buildCheckInstructions } from "./check-instructions.mjs";
4
2
  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("");
3
+ const parts = [];
4
+ // Header — scan-friendly status lines for quick at-a-glance review
5
+ parts.push(`\nPR #${report.pr} — ${report.repo}`);
6
+ parts.push(`Status: ${report.status}`);
7
+ parts.push(`Base: ${report.baseBranch}`);
8
+ parts.push("");
10
9
  // Merge status
11
10
  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("");
11
+ parts.push("## Merge Status");
12
+ parts.push("");
13
+ parts.push(`${ms.status}`);
14
+ parts.push(` mergeStateStatus: ${ms.mergeStateStatus}`);
15
+ parts.push(` mergeable: ${ms.mergeable}`);
16
+ parts.push(` reviewDecision: ${ms.reviewDecision ?? "(none)"}`);
17
+ parts.push(` isDraft: ${ms.isDraft}`);
18
+ parts.push(` copilotReviewInProgress: ${ms.copilotReviewInProgress}`);
19
+ parts.push("");
19
20
  // CI checks
20
21
  const { passing, failing, inProgress, skipped } = report.checks;
21
22
  const total = passing.length + failing.length + inProgress.length + skipped.length;
22
- lines.push(`CI Checks: ${passing.length}/${total} passed`);
23
+ parts.push("## CI Checks");
24
+ parts.push("");
25
+ parts.push(`${passing.length}/${total} passed`);
26
+ parts.push("");
23
27
  if (failing.length > 0) {
24
- lines.push(`\nFailed Checks (${failing.length}):`);
28
+ parts.push(`### Failed (${failing.length})`);
29
+ parts.push("");
25
30
  for (const c of failing) {
26
31
  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"), " "));
32
+ const prefix = triaged.workflowName ? `${triaged.workflowName}` : "";
33
+ parts.push(`- ${prefix}${c.name}: ${c.conclusion ?? c.status}`);
34
+ if (triaged.failedStep) {
35
+ parts.push(` failed step: ${triaged.failedStep}`);
36
+ }
37
+ if (triaged.summary) {
38
+ parts.push(` summary: ${triaged.summary}`);
31
39
  }
32
40
  }
41
+ parts.push("");
33
42
  }
34
43
  if (inProgress.length > 0) {
35
- lines.push(`\nIn Progress (${inProgress.length}):`);
44
+ parts.push(`### In Progress (${inProgress.length})`);
45
+ parts.push("");
36
46
  for (const c of inProgress) {
37
- lines.push(` - ${c.name}: ${c.status}`);
47
+ parts.push(`- ${c.name}: ${c.status}`);
38
48
  }
49
+ parts.push("");
39
50
  }
40
51
  if (skipped.length > 0) {
41
- lines.push(`\nSkipped (${skipped.length}): ${skipped.map((c) => c.name).join(", ")}`);
52
+ parts.push(`### Skipped (${skipped.length}): ${skipped.map((c) => c.name).join(", ")}`);
53
+ parts.push("");
42
54
  }
43
55
  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(", ")}`);
56
+ parts.push(`### Filtered non-PR-trigger (${report.checks.filtered.length}): ${report.checks.filtered.map((c) => c.name).join(", ")}`);
57
+ parts.push("");
45
58
  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.");
59
+ parts.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
60
  }
48
61
  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.");
62
+ parts.push(" Note: one or more of these filtered checks may be a required status check blocking merge.");
50
63
  }
64
+ parts.push("");
51
65
  }
52
- lines.push("");
53
66
  // Review threads
54
67
  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})`);
68
+ const hasThreadSection = autoResolved.length > 0 || autoResolveErrors.length > 0 || actionableThreads.length > 0;
69
+ if (hasThreadSection) {
70
+ parts.push("## Review Threads");
71
+ parts.push("");
72
+ if (autoResolved.length > 0) {
73
+ parts.push(`Auto-resolved outdated (${autoResolved.length}):`);
74
+ for (const t of autoResolved) {
75
+ parts.push(`- threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author})`);
76
+ }
77
+ parts.push("");
59
78
  }
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}`);
79
+ if (autoResolveErrors.length > 0) {
80
+ parts.push(`Auto-resolve errors (${autoResolveErrors.length}):`);
81
+ for (const e of autoResolveErrors) {
82
+ parts.push(`- ${e}`);
83
+ }
84
+ parts.push("");
66
85
  }
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)}`);
86
+ if (actionableThreads.length > 0) {
87
+ parts.push(`### Actionable (${actionableThreads.length})`);
88
+ parts.push("");
89
+ for (const t of actionableThreads) {
90
+ const label = t.path ? `${t.path}:${t.line ?? "?"}` : "(general)";
91
+ parts.push(`- threadId=${t.id} ${label} (@${t.author})`);
92
+ parts.push(` ${firstLine(t.body)}`);
93
+ }
94
+ parts.push("");
75
95
  }
76
- lines.push("");
77
96
  }
78
97
  // PR comments
79
98
  const { actionable: actionableComments } = report.comments;
80
99
  if (actionableComments.length > 0) {
81
- lines.push(`Actionable PR Comments (${actionableComments.length}):`);
100
+ parts.push("## PR Comments");
101
+ parts.push("");
102
+ parts.push(`### Actionable (${actionableComments.length})`);
103
+ parts.push("");
82
104
  for (const c of actionableComments) {
83
- lines.push(` - commentId=${c.id} (@${c.author}): ${firstLine(c.body)}`);
105
+ parts.push(`- commentId=${c.id} (@${c.author}): ${firstLine(c.body)}`);
84
106
  }
85
- lines.push("");
107
+ parts.push("");
86
108
  }
87
109
  // CHANGES_REQUESTED reviews
88
110
  if (report.changesRequestedReviews.length > 0) {
89
- lines.push(`Pending CHANGES_REQUESTED reviews (${report.changesRequestedReviews.length}):`);
111
+ parts.push("## CHANGES_REQUESTED Reviews");
112
+ parts.push("");
90
113
  for (const r of report.changesRequestedReviews) {
91
- lines.push(` - reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
114
+ parts.push(`- reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
92
115
  }
93
- lines.push("");
116
+ parts.push("");
117
+ }
118
+ // Review summaries
119
+ if (report.reviewSummaries.length > 0) {
120
+ parts.push("## Review Summaries");
121
+ parts.push("");
122
+ for (const r of report.reviewSummaries) {
123
+ parts.push(`- reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
124
+ }
125
+ parts.push("");
126
+ }
127
+ // Approved reviews
128
+ if (report.approvedReviews.length > 0) {
129
+ parts.push("## Approved Reviews");
130
+ parts.push("");
131
+ for (const r of report.approvedReviews) {
132
+ parts.push(`- reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
133
+ }
134
+ parts.push("");
94
135
  }
95
136
  // Summary
96
137
  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");
138
+ parts.push("## Summary");
139
+ parts.push("");
140
+ parts.push(totalActionable === 0
141
+ ? "0 actionable — all threads resolved/minimized"
142
+ : `${totalActionable} actionable item(s) remaining`);
143
+ parts.push("");
144
+ // Instructions
145
+ parts.push("## Instructions");
146
+ parts.push("");
147
+ const instructions = buildCheckInstructions(report);
148
+ instructions.forEach((step, i) => {
149
+ parts.push(`${i + 1}. ${step}`);
150
+ });
151
+ return parts.join("\n");
99
152
  }
100
153
  // ---------------------------------------------------------------------------
101
154
  // Helpers
@@ -103,9 +156,3 @@ export function formatText(report) {
103
156
  function firstLine(text) {
104
157
  return (text.split("\n")[0] ?? "").trim().slice(0, 120);
105
158
  }
106
- function indent(text, prefix) {
107
- return text
108
- .split("\n")
109
- .map((l) => prefix + l)
110
- .join("\n");
111
- }
@@ -5,7 +5,7 @@
5
5
  * handler without being resolved. Counts are reset automatically when the HEAD
6
6
  * commit SHA changes (i.e. a new push landed).
7
7
  *
8
- * State lives in `$TMPDIR/pr-shepherd-cache/<owner>-<repo>/<pr>/fix-attempts.json`.
8
+ * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/fix-attempts.json`.
9
9
  */
10
10
  import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
11
11
  import { randomUUID } from "node:crypto";
@@ -59,9 +59,9 @@ function resolvePath(key) {
59
59
  ["repo", key.repo],
60
60
  ]) {
61
61
  if (!SAFE_SEGMENT.test(value)) {
62
- throw new Error(`Invalid cache key segment "${field}": ${value}`);
62
+ throw new Error(`Invalid state key segment "${field}": ${value}`);
63
63
  }
64
64
  }
65
- const base = process.env["PR_SHEPHERD_CACHE_DIR"] ?? join(tmpdir(), "pr-shepherd-cache");
65
+ const base = process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
66
66
  return join(base, `${key.owner}-${key.repo}`, String(key.pr), "fix-attempts.json");
67
67
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Persistent stall-detection state for the iterate loop.
3
+ *
4
+ * Tracks the fingerprint of the last iterate result and when that fingerprint
5
+ * was first seen. If the fingerprint does not change for stallTimeoutSeconds
6
+ * the iterate command escalates instead of repeating the same action.
7
+ *
8
+ * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/iterate-stall.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 stall state. Returns null on miss, corrupt data, or invalid shape. */
19
+ export async function readStallState(key) {
20
+ try {
21
+ const raw = await readFile(resolvePath(key), "utf8");
22
+ const parsed = JSON.parse(raw);
23
+ if (parsed === null ||
24
+ typeof parsed !== "object" ||
25
+ typeof parsed["fingerprint"] !== "string" ||
26
+ !Number.isFinite(parsed["firstSeenAt"])) {
27
+ return null;
28
+ }
29
+ return parsed;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /** Write stall state (fire-and-forget — never throws). */
36
+ export async function writeStallState(key, state) {
37
+ let tmp;
38
+ try {
39
+ const path = resolvePath(key);
40
+ tmp = `${path}.${randomUUID()}.tmp`;
41
+ await mkdir(dirname(path), { recursive: true });
42
+ await writeFile(tmp, JSON.stringify(state), "utf8");
43
+ await rename(tmp, path);
44
+ tmp = undefined;
45
+ }
46
+ catch {
47
+ // Best-effort.
48
+ }
49
+ finally {
50
+ if (tmp !== undefined) {
51
+ try {
52
+ await unlink(tmp);
53
+ }
54
+ catch {
55
+ // Best-effort cleanup.
56
+ }
57
+ }
58
+ }
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Helpers
62
+ // ---------------------------------------------------------------------------
63
+ function resolvePath(key) {
64
+ for (const [field, value] of [
65
+ ["owner", key.owner],
66
+ ["repo", key.repo],
67
+ ]) {
68
+ if (!SAFE_SEGMENT.test(value)) {
69
+ throw new Error(`Invalid state key segment "${field}": ${value}`);
70
+ }
71
+ }
72
+ const base = process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
73
+ return join(base, `${key.owner}-${key.repo}`, String(key.pr), "iterate-stall.json");
74
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Parse GitHub review-comment "suggestion" blocks.
3
+ *
4
+ * GitHub's "Commit suggestion" UI button treats the first ```suggestion fenced
5
+ * block in a review comment as a replacement for the commented line range.
6
+ * This module extracts that block from the comment body, plus applies a
7
+ * parsed suggestion to a file's contents.
8
+ *
9
+ * There is no GitHub API for applying suggestions — tools that reproduce the
10
+ * button (this one included) must parse + commit themselves.
11
+ */
12
+ /**
13
+ * Return the first ```suggestion block from a review-comment body, or null if none.
14
+ *
15
+ * Handles three distinct cases:
16
+ * - Empty block (` ```suggestion\n``` `) → `lines: []` (deletion).
17
+ * - Blank-line-only body (` ```suggestion\n\n``` `) → `lines: [""]`.
18
+ * - Non-empty body → split into lines.
19
+ *
20
+ * The opening fence may be 3+ backticks; the closing fence must be at least
21
+ * as many backticks, at the start of a line (after the captured prefix). This
22
+ * means content lines that contain ` ``` ` in the middle (not at line-start)
23
+ * are treated as content rather than a closing fence — fixing the silent
24
+ * truncation in the original regex approach (issue #68).
25
+ *
26
+ * When the block is embedded in a quoted reply (e.g. `> ```suggestion …`),
27
+ * the leading prefix captured from the opening fence is stripped from each
28
+ * body line — but only when that exact prefix is present, so legitimate `>`
29
+ * characters inside the suggested code survive.
30
+ */
31
+ export function parseSuggestion(body) {
32
+ const lines = body.split("\n");
33
+ // Find the opening fence: optional prefix + N backticks (N≥3) + "suggestion" + anything.
34
+ let openIdx = -1;
35
+ let prefix = "";
36
+ let fenceLen = 0;
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const m = /^([ \t>]*?)(`{3,})suggestion[^\n]*$/.exec(lines[i]);
39
+ if (m) {
40
+ openIdx = i;
41
+ prefix = m[1];
42
+ fenceLen = m[2].length;
43
+ break;
44
+ }
45
+ }
46
+ if (openIdx === -1)
47
+ return null;
48
+ // Find the closing fence: same prefix + N+ backticks at the start of a line.
49
+ const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
+ const closeRegex = new RegExp(`^${escapedPrefix}\`{${fenceLen},}[ \\t]*$`);
51
+ let closeIdx = -1;
52
+ for (let i = openIdx + 1; i < lines.length; i++) {
53
+ if (closeRegex.test(lines[i])) {
54
+ closeIdx = i;
55
+ break;
56
+ }
57
+ }
58
+ let bodyLines;
59
+ if (closeIdx !== -1) {
60
+ bodyLines = lines.slice(openIdx + 1, closeIdx);
61
+ }
62
+ else if (prefix === "" && lines.length > openIdx + 1) {
63
+ // Inline-close fallback: last line ends with N+ backticks (no trailing newline).
64
+ // Preserves the historical behaviour for bodies like "```suggestion\nfoo```".
65
+ // Only supported for non-quoted (prefix="") blocks.
66
+ const last = lines[lines.length - 1];
67
+ const m = new RegExp(`^(.*?)\`{${fenceLen},}$`).exec(last);
68
+ if (!m)
69
+ return null;
70
+ bodyLines = [...lines.slice(openIdx + 1, lines.length - 1), m[1]];
71
+ }
72
+ else {
73
+ return null;
74
+ }
75
+ if (bodyLines.length === 0)
76
+ return { lines: [] };
77
+ const cleaned = prefix === ""
78
+ ? bodyLines
79
+ : bodyLines.map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : l));
80
+ return { lines: cleaned };
81
+ }
82
+ /**
83
+ * True when a parsed suggestion's replacement is safe to commit: the joined
84
+ * replacement contains no nested ` ```suggestion ` marker and no unmatched
85
+ * ` ``` ` run (odd count). Both shapes previously masked silent truncation
86
+ * (issue #68). Conservative by design: reviewers whose suggestion content
87
+ * legitimately includes these markers must apply the change manually.
88
+ */
89
+ export function isCommittableSuggestion(parsed) {
90
+ const replacement = parsed.lines.join("\n");
91
+ if (replacement.includes("```suggestion"))
92
+ return false;
93
+ const fenceRuns = (replacement.match(/`{3,}/g) ?? []).length;
94
+ return fenceRuns % 2 === 0;
95
+ }
96
+ /**
97
+ * Apply a suggestion to a file's full contents by replacing lines
98
+ * [startLine..endLine] (1-indexed, inclusive) with the given replacement lines.
99
+ *
100
+ * Pass `[]` to delete the range, `[""]` to replace with a single blank line,
101
+ * or `["a", "b", ...]` for arbitrary replacements. The file's trailing-newline
102
+ * state is preserved exactly.
103
+ */
104
+ export function applySuggestionToFile(fileContent, startLine, endLine, replacementLines) {
105
+ if (startLine < 1 || endLine < startLine) {
106
+ throw new Error(`Invalid line range: start=${startLine}, end=${endLine}`);
107
+ }
108
+ const endsWithNewline = fileContent.endsWith("\n");
109
+ // Strip a single trailing \n so split/join round-trips exactly.
110
+ const body = endsWithNewline ? fileContent.slice(0, -1) : fileContent;
111
+ const lines = body.split("\n");
112
+ if (endLine > lines.length) {
113
+ throw new Error(`Line ${endLine} is out of range (file has ${lines.length} line(s))`);
114
+ }
115
+ const before = lines.slice(0, startLine - 1);
116
+ const after = lines.slice(endLine);
117
+ const result = [...before, ...replacementLines, ...after].join("\n");
118
+ return endsWithNewline ? `${result}\n` : result;
119
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Build a git-apply-compatible unified diff for a single suggestion hunk.
3
+ *
4
+ * The diff uses `--- a/<path>` / `+++ b/<path>` headers so `git apply` and
5
+ * `git apply --check` accept it without a `diff --git` preamble.
6
+ */
7
+ export function buildUnifiedDiff({ path, originalContent, startLine, endLine, replacementLines, context = 3, }) {
8
+ const endsWithNewline = originalContent.endsWith("\n");
9
+ const body = endsWithNewline ? originalContent.slice(0, -1) : originalContent;
10
+ const fileLines = body === "" ? [] : body.split("\n");
11
+ const removedLines = fileLines.slice(startLine - 1, endLine);
12
+ const beforeStart = Math.max(0, startLine - 1 - context);
13
+ const beforeLines = fileLines.slice(beforeStart, startLine - 1);
14
+ const afterEnd = Math.min(fileLines.length, endLine + context);
15
+ const afterLines = fileLines.slice(endLine, afterEnd);
16
+ const hunkOrigStart = beforeStart + 1;
17
+ const hunkOrigCount = beforeLines.length + removedLines.length + afterLines.length;
18
+ const hunkNewCount = beforeLines.length + replacementLines.length + afterLines.length;
19
+ const noNewline = "\\n";
20
+ const isLastOrigLine = (lineIdx) => !endsWithNewline && lineIdx === fileLines.length - 1;
21
+ const out = [
22
+ `--- a/${path}\n`,
23
+ `+++ b/${path}\n`,
24
+ `@@ -${hunkOrigStart},${hunkOrigCount} +${hunkOrigStart},${hunkNewCount} @@\n`,
25
+ ];
26
+ for (let i = 0; i < beforeLines.length; i++) {
27
+ out.push(` ${beforeLines[i]}\n`);
28
+ if (isLastOrigLine(beforeStart + i))
29
+ out.push(noNewline);
30
+ }
31
+ for (let i = 0; i < removedLines.length; i++) {
32
+ out.push(`-${removedLines[i]}\n`);
33
+ if (isLastOrigLine(startLine - 1 + i))
34
+ out.push(noNewline);
35
+ }
36
+ // Replacement ends the file only when the removed range reaches the very last line —
37
+ // not just because context is 0 (there may still be unshown lines beyond the hunk).
38
+ const addedEndsFile = !endsWithNewline && endLine >= fileLines.length;
39
+ const hasCr = originalContent.includes("\r\n");
40
+ for (let i = 0; i < replacementLines.length; i++) {
41
+ const line = hasCr ? replacementLines[i] + "\r" : replacementLines[i];
42
+ out.push(`+${line}\n`);
43
+ if (addedEndsFile && i === replacementLines.length - 1)
44
+ out.push(noNewline);
45
+ }
46
+ for (let i = 0; i < afterLines.length; i++) {
47
+ out.push(` ${afterLines[i]}\n`);
48
+ if (isLastOrigLine(endLine + i))
49
+ out.push(noNewline);
50
+ }
51
+ return out.join("");
52
+ }
@@ -0,0 +1,2 @@
1
+ // GitHub primitives, check runs, review threads, and batch PR data types.
2
+ export {};
@@ -0,0 +1,2 @@
1
+ // Iterate command types.
2
+ export {};
@@ -0,0 +1,2 @@
1
+ // Shepherd-specific report types, agent projections, and CLI options.
2
+ export {};
package/bin/types.mjs CHANGED
@@ -1,2 +1,4 @@
1
1
  /** Shared type definitions for the shepherd CLI. */
2
- export {};
2
+ export * from "./types/github.mjs";
3
+ export * from "./types/report.mjs";
4
+ export * from "./types/iterate.mjs";
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.7.0",
3
+ "version": "0.8.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": "bin/index.mjs"
9
+ "pr-shepherd": "bin/pr-shepherd"
10
10
  },
11
11
  "files": [
12
12
  "bin/**",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "scripts": {
34
34
  "build": "node scripts/build.mjs",
35
- "prepare": "npm run build",
35
+ "prepare": "node scripts/install-hooks.mjs && node scripts/install-plugin-symlink.mjs && npm run build",
36
36
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
37
37
  "typecheck": "tsc --noEmit",
38
38
  "lint": "oxlint src/ plugin/skills/",
@@ -10,59 +10,26 @@ allowed-tools: ["Bash"]
10
10
 
11
11
  ## Arguments: $ARGUMENTS
12
12
 
13
- ## Resolve PR number(s)
13
+ ## Steps
14
14
 
15
- 1. If `$ARGUMENTS` contains PR numbers or GitHub PR URLs, extract the number(s).
16
- 2. Otherwise, infer: `gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number --jq '.[0].number'`
17
- 3. If no PR found, report an error and stop.
15
+ 1. **Parse `$ARGUMENTS`:** extract PR numbers or GitHub PR URLs. If none, infer:
16
+ `gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number --jq '.[0].number'`
17
+ If no PR found, report an error and stop.
18
18
 
19
- For each resolved PR number, check if it is already merged:
19
+ 2. **Short-circuit if merged:**
20
20
 
21
- ```bash
22
- gh pr view <N> --json state --jq '.state'
23
- ```
21
+ ```bash
22
+ gh pr view <N> --json state --jq '.state'
23
+ ```
24
24
 
25
- If `MERGED`, output: `PR #N is already merged. Nothing to check.` and skip.
25
+ If `MERGED`, output: `PR #N is already merged. Nothing to check.` and skip.
26
26
 
27
- ## Run the check
27
+ 3. **Run the check and follow instructions:**
28
28
 
29
- ```bash
30
- npx pr-shepherd check <N> --format=json
31
- ```
29
+ ```bash
30
+ npx pr-shepherd check <N>
31
+ ```
32
32
 
33
- ## Reporting
33
+ Print the full output. Follow the `## Instructions` section exactly.
34
34
 
35
- Parse the JSON output and report all three:
36
-
37
- - **Merge status** (`report.mergeStatus.status`): CLEAN | BEHIND | CONFLICTS | BLOCKED | UNSTABLE | DRAFT | UNKNOWN — never omit; include `copilotReviewInProgress` when true
38
- - **CI check results** (`report.checks`): passing count, failing names + kinds, in-progress names
39
- - **Unresolved review comments** (`report.threads.actionable` + `report.comments.actionable`): count + details with file paths and line numbers
40
-
41
- ## Rebase policy
42
-
43
- The CLI already determines whether a rebase is warranted. Read `report.mergeStatus.status` directly:
44
-
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
-
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.
50
-
51
- ## CI budget policy
52
-
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`.
59
-
60
- ## Never declare ready to merge
61
-
62
- Unless ALL of:
63
-
64
- 1. `report.mergeStatus.mergeStateStatus == 'CLEAN'`
65
- 2. `report.status == 'READY'`
66
- 3. `report.mergeStatus.copilotReviewInProgress == false`
67
-
68
- This is a one-shot check. For continuous monitoring, use `/pr-shepherd:monitor`.
35
+ 4. For multiple PRs, repeat steps 2–3 for each.