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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +37 -302
- package/bin/checks/classify.mjs +5 -4
- package/bin/checks/triage.mjs +76 -62
- package/bin/cli/args.mjs +29 -61
- package/bin/cli/exit-codes.mjs +39 -0
- package/bin/cli/fix-formatter.mjs +76 -0
- package/bin/cli/formatters.mjs +108 -0
- package/bin/cli/handlers.mjs +138 -0
- package/bin/cli/iterate-formatter.mjs +78 -0
- package/bin/cli-parser.iterate-fixtures.mjs +65 -0
- package/bin/cli-parser.mjs +110 -0
- package/bin/commands/check-status.mjs +35 -0
- package/bin/commands/check.mjs +14 -61
- package/bin/commands/commit-suggestion.mjs +159 -0
- package/bin/commands/iterate/classify.mjs +77 -0
- package/bin/commands/iterate/escalate.mjs +124 -0
- package/bin/commands/iterate/fix-code.mjs +97 -0
- package/bin/commands/iterate/helpers.mjs +103 -0
- package/bin/commands/iterate/index.mjs +122 -0
- package/bin/commands/iterate/render.mjs +119 -0
- package/bin/commands/iterate/stall.mjs +65 -0
- package/bin/commands/iterate/steps.mjs +31 -0
- package/bin/commands/iterate.mjs +2 -628
- package/bin/commands/monitor.mjs +78 -0
- package/bin/commands/ready-delay.mjs +3 -4
- package/bin/commands/resolve-instructions.mjs +39 -0
- package/bin/commands/resolve.mjs +34 -3
- package/bin/commands/status.mjs +7 -0
- package/bin/comments/resolve.mjs +1 -1
- package/bin/config/load.mjs +17 -113
- package/bin/config.json +10 -22
- package/bin/github/batch-parsers.mjs +140 -0
- package/bin/github/batch-raw-types.mjs +2 -0
- package/bin/github/batch.mjs +34 -129
- package/bin/github/client.mjs +47 -9
- package/bin/github/gql/batch-pr.gql +20 -0
- package/bin/github/http.mjs +32 -30
- package/bin/index.mjs +15 -2
- package/bin/merge-status/derive.mjs +11 -11
- package/bin/reporters/agent.mjs +13 -4
- package/bin/reporters/check-instructions.mjs +65 -0
- package/bin/reporters/json.mjs +3 -2
- package/bin/reporters/text.mjs +108 -61
- package/bin/{cache → state}/fix-attempts.mjs +3 -3
- package/bin/state/iterate-stall.mjs +74 -0
- package/bin/suggestions/parse.mjs +119 -0
- package/bin/suggestions/patch.mjs +52 -0
- package/bin/types/github.mjs +2 -0
- package/bin/types/iterate.mjs +2 -0
- package/bin/types/report.mjs +2 -0
- package/bin/types.mjs +3 -1
- package/package.json +3 -3
- package/plugin/skills/check/SKILL.md +15 -48
- package/plugin/skills/monitor/SKILL.md +11 -64
- package/plugin/skills/resolve/SKILL.md +10 -76
- package/bin/cache/file-cache.mjs +0 -79
- package/bin/cli.mjs +0 -286
package/bin/reporters/text.mjs
CHANGED
|
@@ -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
|
|
6
|
-
// Header
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
+
parts.push(`### Failed (${failing.length})`);
|
|
29
|
+
parts.push("");
|
|
25
30
|
for (const c of failing) {
|
|
26
31
|
const triaged = c;
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
if (triaged.
|
|
30
|
-
|
|
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
|
-
|
|
44
|
+
parts.push(`### In Progress (${inProgress.length})`);
|
|
45
|
+
parts.push("");
|
|
36
46
|
for (const c of inProgress) {
|
|
37
|
-
|
|
47
|
+
parts.push(`- ${c.name}: ${c.status}`);
|
|
38
48
|
}
|
|
49
|
+
parts.push("");
|
|
39
50
|
}
|
|
40
51
|
if (skipped.length > 0) {
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
parts.push(`- commentId=${c.id} (@${c.author}): ${firstLine(c.body)}`);
|
|
84
106
|
}
|
|
85
|
-
|
|
107
|
+
parts.push("");
|
|
86
108
|
}
|
|
87
109
|
// CHANGES_REQUESTED reviews
|
|
88
110
|
if (report.changesRequestedReviews.length > 0) {
|
|
89
|
-
|
|
111
|
+
parts.push("## CHANGES_REQUESTED Reviews");
|
|
112
|
+
parts.push("");
|
|
90
113
|
for (const r of report.changesRequestedReviews) {
|
|
91
|
-
|
|
114
|
+
parts.push(`- reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
|
|
92
115
|
}
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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-
|
|
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
|
|
62
|
+
throw new Error(`Invalid state key segment "${field}": ${value}`);
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
-
const base = process.env["
|
|
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
|
+
}
|
package/bin/types.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "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/
|
|
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
|
-
##
|
|
13
|
+
## Steps
|
|
14
14
|
|
|
15
|
-
1.
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
+
3. **Run the check and follow instructions:**
|
|
28
28
|
|
|
29
|
-
```bash
|
|
30
|
-
npx pr-shepherd check <N>
|
|
31
|
-
```
|
|
29
|
+
```bash
|
|
30
|
+
npx pr-shepherd check <N>
|
|
31
|
+
```
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
Print the full output. Follow the `## Instructions` section exactly.
|
|
34
34
|
|
|
35
|
-
|
|
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.
|