pr-shepherd 0.8.1 → 0.10.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 +123 -21
- package/bin/checks/classify.mjs +2 -1
- package/bin/checks/triage.mjs +63 -61
- package/bin/cli/args.mjs +9 -3
- package/bin/cli/fence.mjs +4 -0
- package/bin/cli/fix-formatter.mjs +45 -19
- package/bin/cli/formatters.mjs +61 -44
- package/bin/cli/handlers.mjs +5 -4
- package/bin/cli/iterate-formatter.mjs +51 -45
- package/bin/cli/iterate-lean.mjs +95 -0
- package/bin/cli/list-formatters.mjs +32 -0
- package/bin/cli/suggestion-renderer.mjs +31 -0
- package/bin/cli-parser.iterate-fixtures.mjs +6 -5
- package/bin/cli-parser.mjs +27 -3
- package/bin/commands/check-status.mjs +7 -4
- package/bin/commands/check.mjs +39 -3
- package/bin/commands/commit-suggestion.mjs +12 -10
- package/bin/commands/iterate/classify.mjs +10 -54
- package/bin/commands/iterate/escalate.mjs +5 -14
- package/bin/commands/iterate/fix-code.mjs +15 -16
- package/bin/commands/iterate/helpers.mjs +8 -5
- package/bin/commands/iterate/index.mjs +18 -12
- package/bin/commands/iterate/render.mjs +28 -12
- package/bin/commands/iterate/stall.mjs +1 -1
- package/bin/commands/log-file.mjs +7 -0
- package/bin/commands/monitor.mjs +7 -4
- package/bin/commands/ready-delay.mjs +2 -2
- package/bin/commands/resolve-instructions.mjs +14 -5
- package/bin/commands/resolve.mjs +38 -37
- package/bin/commands/status.mjs +41 -29
- package/bin/comments/resolve.mjs +75 -65
- package/bin/config.json +4 -7
- package/bin/github/batch-parsers.mjs +4 -0
- package/bin/github/client.mjs +11 -32
- package/bin/github/gql/batch-pr.gql +6 -0
- package/bin/github/gql/get-pr-head-sha.gql +7 -0
- package/bin/github/http.mjs +166 -11
- package/bin/github/queries.mjs +7 -11
- package/bin/log/log-file.mjs +88 -0
- package/bin/log/session.mjs +100 -0
- package/bin/log/setup.mjs +54 -0
- package/bin/reporters/agent.mjs +21 -13
- package/bin/reporters/check-instructions.mjs +9 -14
- package/bin/reporters/text.mjs +93 -105
- package/bin/state/base.mjs +5 -0
- package/bin/state/fix-attempts.mjs +2 -2
- package/bin/state/iterate-stall.mjs +2 -2
- package/bin/state/seen-comments.mjs +76 -0
- package/bin/suggestions/extract.mjs +15 -0
- package/bin/suggestions/parse.mjs +1 -26
- package/bin/types/report.mjs +0 -1
- package/bin/util/markdown.mjs +7 -0
- package/bin/util/worktree.mjs +23 -0
- package/package.json +2 -2
- package/bin/commands/iterate/steps.mjs +0 -31
- package/bin/github/gql/dismiss-review.gql +0 -7
- package/bin/github/gql/minimize-comment.gql +0 -7
- package/bin/github/gql/multi-pr-status.gql +0 -32
- package/bin/github/gql/resolve-thread.gql +0 -7
package/bin/reporters/text.mjs
CHANGED
|
@@ -1,158 +1,146 @@
|
|
|
1
1
|
import { buildCheckInstructions } from "./check-instructions.mjs";
|
|
2
|
+
import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, } from "../cli/list-formatters.mjs";
|
|
3
|
+
import { joinSections } from "../util/markdown.mjs";
|
|
2
4
|
export function formatText(report) {
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
parts.push("");
|
|
9
|
-
// Merge status
|
|
5
|
+
const header = [
|
|
6
|
+
`# PR #${report.pr} [CHECK] — ${report.repo}`,
|
|
7
|
+
`Status: ${report.status}`,
|
|
8
|
+
`Base: ${report.baseBranch}`,
|
|
9
|
+
].join("\n");
|
|
10
10
|
const ms = report.mergeStatus;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
const mergeStatusSection = [
|
|
12
|
+
"## Merge Status",
|
|
13
|
+
"",
|
|
14
|
+
`- status: \`${ms.status}\``,
|
|
15
|
+
`- mergeStateStatus: \`${ms.mergeStateStatus}\``,
|
|
16
|
+
`- mergeable: \`${ms.mergeable}\``,
|
|
17
|
+
`- reviewDecision: \`${ms.reviewDecision ?? "(none)"}\``,
|
|
18
|
+
`- isDraft: \`${ms.isDraft}\``,
|
|
19
|
+
`- copilotReviewInProgress: \`${ms.copilotReviewInProgress}\``,
|
|
20
|
+
].join("\n");
|
|
21
21
|
const { passing, failing, inProgress, skipped } = report.checks;
|
|
22
22
|
const total = passing.length + failing.length + inProgress.length + skipped.length;
|
|
23
|
-
|
|
24
|
-
parts.push("");
|
|
25
|
-
parts.push(`${passing.length}/${total} passed`);
|
|
26
|
-
parts.push("");
|
|
23
|
+
const ciSubsections = [`${passing.length}/${total} passed`];
|
|
27
24
|
if (failing.length > 0) {
|
|
28
|
-
|
|
29
|
-
parts.push("");
|
|
25
|
+
const lines = [`### Failed (${failing.length})`, ""];
|
|
30
26
|
for (const c of failing) {
|
|
31
27
|
const triaged = c;
|
|
32
28
|
const prefix = triaged.workflowName ? `${triaged.workflowName} › ` : "";
|
|
33
|
-
|
|
29
|
+
lines.push(`- ${prefix}${c.name}: ${c.conclusion ?? c.status}`);
|
|
34
30
|
if (triaged.failedStep) {
|
|
35
|
-
|
|
31
|
+
lines.push(` failed step: ${triaged.failedStep}`);
|
|
36
32
|
}
|
|
37
33
|
if (triaged.summary) {
|
|
38
|
-
|
|
34
|
+
lines.push(` summary: ${triaged.summary}`);
|
|
39
35
|
}
|
|
40
36
|
}
|
|
41
|
-
|
|
37
|
+
ciSubsections.push(lines.join("\n"));
|
|
42
38
|
}
|
|
43
39
|
if (inProgress.length > 0) {
|
|
44
|
-
|
|
45
|
-
parts.push("");
|
|
40
|
+
const lines = [`### In Progress (${inProgress.length})`, ""];
|
|
46
41
|
for (const c of inProgress) {
|
|
47
|
-
|
|
42
|
+
lines.push(`- ${c.name}: ${c.status}`);
|
|
48
43
|
}
|
|
49
|
-
|
|
44
|
+
ciSubsections.push(lines.join("\n"));
|
|
50
45
|
}
|
|
51
46
|
if (skipped.length > 0) {
|
|
52
|
-
|
|
53
|
-
parts.push("");
|
|
47
|
+
ciSubsections.push(`### Skipped (${skipped.length}): ${skipped.map((c) => c.name).join(", ")}`);
|
|
54
48
|
}
|
|
55
49
|
if (report.checks.filtered.length > 0) {
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
const lines = [
|
|
51
|
+
`### Filtered non-PR-trigger (${report.checks.filtered.length}): ${report.checks.filtered.map((c) => c.name).join(", ")}`,
|
|
52
|
+
"",
|
|
53
|
+
];
|
|
58
54
|
if (report.checks.blockedByFilteredCheck) {
|
|
59
|
-
|
|
55
|
+
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.");
|
|
60
56
|
}
|
|
61
57
|
else if (report.mergeStatus.status === "BLOCKED") {
|
|
62
|
-
|
|
58
|
+
lines.push("> Note: one or more of these filtered checks may be a required status check blocking merge.");
|
|
63
59
|
}
|
|
64
|
-
|
|
60
|
+
ciSubsections.push(lines.join("\n"));
|
|
65
61
|
}
|
|
66
|
-
|
|
67
|
-
const { actionable: actionableThreads, autoResolved, autoResolveErrors } = report.threads;
|
|
62
|
+
const ciSection = `## CI Checks\n\n${ciSubsections.join("\n\n")}`;
|
|
63
|
+
const { actionable: actionableThreads, autoResolved, autoResolveErrors, firstLook: firstLookThreads, } = report.threads;
|
|
68
64
|
const hasThreadSection = autoResolved.length > 0 || autoResolveErrors.length > 0 || actionableThreads.length > 0;
|
|
65
|
+
let threadsSection = null;
|
|
69
66
|
if (hasThreadSection) {
|
|
70
|
-
|
|
71
|
-
parts.push("");
|
|
67
|
+
const subparts = [];
|
|
72
68
|
if (autoResolved.length > 0) {
|
|
73
|
-
|
|
69
|
+
const lines = [`Auto-resolved outdated (${autoResolved.length}):`];
|
|
74
70
|
for (const t of autoResolved) {
|
|
75
|
-
|
|
71
|
+
lines.push(renderThreadBullet(t));
|
|
76
72
|
}
|
|
77
|
-
|
|
73
|
+
subparts.push(lines.join("\n"));
|
|
78
74
|
}
|
|
79
75
|
if (autoResolveErrors.length > 0) {
|
|
80
|
-
|
|
76
|
+
const lines = [`Auto-resolve errors (${autoResolveErrors.length}):`];
|
|
81
77
|
for (const e of autoResolveErrors) {
|
|
82
|
-
|
|
78
|
+
lines.push(`- ${e}`);
|
|
83
79
|
}
|
|
84
|
-
|
|
80
|
+
subparts.push(lines.join("\n"));
|
|
85
81
|
}
|
|
86
82
|
if (actionableThreads.length > 0) {
|
|
87
|
-
|
|
88
|
-
parts.push("");
|
|
83
|
+
const lines = [`### Actionable (${actionableThreads.length})`, ""];
|
|
89
84
|
for (const t of actionableThreads) {
|
|
90
|
-
|
|
91
|
-
parts.push(`- threadId=${t.id} ${label} (@${t.author})`);
|
|
92
|
-
parts.push(` ${firstLine(t.body)}`);
|
|
85
|
+
lines.push(renderThreadBullet(t));
|
|
93
86
|
}
|
|
94
|
-
|
|
87
|
+
subparts.push(lines.join("\n"));
|
|
95
88
|
}
|
|
89
|
+
threadsSection = `## Review Threads\n\n${subparts.join("\n\n")}`;
|
|
96
90
|
}
|
|
97
|
-
|
|
98
|
-
|
|
91
|
+
const { actionable: actionableComments, firstLook: firstLookComments } = report.comments;
|
|
92
|
+
let commentsSection = null;
|
|
99
93
|
if (actionableComments.length > 0) {
|
|
100
|
-
|
|
101
|
-
parts.push("");
|
|
102
|
-
parts.push(`### Actionable (${actionableComments.length})`);
|
|
103
|
-
parts.push("");
|
|
94
|
+
const lines = [`### Actionable (${actionableComments.length})`, ""];
|
|
104
95
|
for (const c of actionableComments) {
|
|
105
|
-
|
|
96
|
+
lines.push(renderCommentBullet(c));
|
|
106
97
|
}
|
|
107
|
-
|
|
98
|
+
commentsSection = `## PR Comments\n\n${lines.join("\n")}`;
|
|
108
99
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
100
|
+
const changesRequestedSection = reviewListSection("CHANGES_REQUESTED Reviews", report.changesRequestedReviews);
|
|
101
|
+
const reviewSummariesSection = reviewListSection("Review Summaries", report.reviewSummaries);
|
|
102
|
+
const approvedReviewsSection = reviewListSection("Approved Reviews", report.approvedReviews);
|
|
103
|
+
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
104
|
+
let firstLookSection = null;
|
|
105
|
+
if (firstLookTotal > 0) {
|
|
106
|
+
const lines = [
|
|
107
|
+
`## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`,
|
|
108
|
+
"",
|
|
109
|
+
];
|
|
110
|
+
for (const t of firstLookThreads) {
|
|
111
|
+
lines.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
|
|
115
112
|
}
|
|
116
|
-
|
|
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)}`);
|
|
113
|
+
for (const c of firstLookComments) {
|
|
114
|
+
lines.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
|
|
133
115
|
}
|
|
134
|
-
|
|
116
|
+
firstLookSection = lines.join("\n");
|
|
135
117
|
}
|
|
136
|
-
// Summary
|
|
137
118
|
const totalActionable = actionableThreads.length + actionableComments.length + report.changesRequestedReviews.length;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
parts.push("## Instructions");
|
|
146
|
-
parts.push("");
|
|
119
|
+
const counts = [];
|
|
120
|
+
if (totalActionable > 0)
|
|
121
|
+
counts.push(`${totalActionable} actionable`);
|
|
122
|
+
if (firstLookTotal > 0)
|
|
123
|
+
counts.push(`${firstLookTotal} first-look`);
|
|
124
|
+
const summaryLine = counts.join(", ") || "0 actionable — all threads resolved/minimized";
|
|
125
|
+
const summarySection = `## Summary\n\n${summaryLine}`;
|
|
147
126
|
const instructions = buildCheckInstructions(report);
|
|
148
|
-
instructions.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
127
|
+
const instructionsSection = `## Instructions\n\n${instructions.map((step, i) => `${i + 1}. ${step}`).join("\n")}`;
|
|
128
|
+
return joinSections([
|
|
129
|
+
header,
|
|
130
|
+
mergeStatusSection,
|
|
131
|
+
ciSection,
|
|
132
|
+
threadsSection,
|
|
133
|
+
commentsSection,
|
|
134
|
+
changesRequestedSection,
|
|
135
|
+
reviewSummariesSection,
|
|
136
|
+
approvedReviewsSection,
|
|
137
|
+
firstLookSection,
|
|
138
|
+
summarySection,
|
|
139
|
+
instructionsSection,
|
|
140
|
+
]);
|
|
152
141
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return (text.split("\n")[0] ?? "").trim().slice(0, 120);
|
|
142
|
+
function reviewListSection(heading, items) {
|
|
143
|
+
if (items.length === 0)
|
|
144
|
+
return null;
|
|
145
|
+
return `## ${heading}\n\n${items.map((r) => renderReviewBullet(r, { includeBody: true })).join("\n")}`;
|
|
158
146
|
}
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
12
|
import { join, dirname } from "node:path";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
13
|
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
14
|
+
import { resolveStateBase } from "./base.mjs";
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Public API
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
@@ -62,6 +62,6 @@ function resolvePath(key) {
|
|
|
62
62
|
throw new Error(`Invalid state key segment "${field}": ${value}`);
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
-
const base =
|
|
65
|
+
const base = resolveStateBase();
|
|
66
66
|
return join(base, `${key.owner}-${key.repo}`, String(key.pr), "fix-attempts.json");
|
|
67
67
|
}
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
12
|
import { join, dirname } from "node:path";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
13
|
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
14
|
+
import { resolveStateBase } from "./base.mjs";
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Public API
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
@@ -69,6 +69,6 @@ function resolvePath(key) {
|
|
|
69
69
|
throw new Error(`Invalid state key segment "${field}": ${value}`);
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
-
const base =
|
|
72
|
+
const base = resolveStateBase();
|
|
73
73
|
return join(base, `${key.owner}-${key.repo}`, String(key.pr), "iterate-stall.json");
|
|
74
74
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, access, readdir } from "node:fs/promises";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
4
|
+
import { resolveStateBase } from "./base.mjs";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Public API
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/**
|
|
9
|
+
* Read the seen/ directory once and return a Set of already-seen IDs.
|
|
10
|
+
* Prefer this over repeated hasSeen() calls to avoid EMFILE on large PRs.
|
|
11
|
+
* Returns an empty Set if the directory does not yet exist.
|
|
12
|
+
*/
|
|
13
|
+
export async function loadSeenSet(key) {
|
|
14
|
+
try {
|
|
15
|
+
const dir = resolveDir(key);
|
|
16
|
+
const entries = await readdir(dir);
|
|
17
|
+
return new Set(entries.filter((e) => e.endsWith(".json")).map((e) => e.slice(0, -5)));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return new Set();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Return true if a "seen" marker exists for this id. */
|
|
24
|
+
export async function hasSeen(key, id) {
|
|
25
|
+
try {
|
|
26
|
+
await access(resolvePath(key, id));
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Write a "seen" marker for this id. Idempotent — preserves original seenAt on double-write. */
|
|
34
|
+
export async function markSeen(key, id) {
|
|
35
|
+
try {
|
|
36
|
+
const path = resolvePath(key, id);
|
|
37
|
+
await mkdir(dirname(path), { recursive: true });
|
|
38
|
+
// O_EXCL: create-only — EEXIST means already marked, which is the idempotent success case.
|
|
39
|
+
// seenAt is unix milliseconds (Date.now()), matching JS convention for this module.
|
|
40
|
+
await writeFile(path, JSON.stringify({ seenAt: Date.now() }), { flag: "wx", encoding: "utf8" });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// EEXIST = already seen. All other errors are best-effort.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Read the full marker for inspection (returns null on miss or error). */
|
|
47
|
+
export async function readSeenMarker(key, id) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = await readFile(resolvePath(key, id), "utf8");
|
|
50
|
+
return JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
function resolveDir(key) {
|
|
60
|
+
for (const [field, value] of [
|
|
61
|
+
["owner", key.owner],
|
|
62
|
+
["repo", key.repo],
|
|
63
|
+
]) {
|
|
64
|
+
if (!SAFE_SEGMENT.test(value)) {
|
|
65
|
+
throw new Error(`Invalid state key segment "${field}": ${value}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const base = resolveStateBase();
|
|
69
|
+
return join(base, `${key.owner}-${key.repo}`, String(key.pr), "seen");
|
|
70
|
+
}
|
|
71
|
+
function resolvePath(key, id) {
|
|
72
|
+
if (!SAFE_SEGMENT.test(id)) {
|
|
73
|
+
throw new Error(`Invalid state key segment "id": ${id}`);
|
|
74
|
+
}
|
|
75
|
+
return join(resolveDir(key), `${id}.json`);
|
|
76
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { parseSuggestion } from "./parse.mjs";
|
|
2
|
+
export function extractSuggestion(thread) {
|
|
3
|
+
if (!thread.path || thread.line === null)
|
|
4
|
+
return null;
|
|
5
|
+
const parsed = parseSuggestion(thread.body);
|
|
6
|
+
if (!parsed)
|
|
7
|
+
return null;
|
|
8
|
+
const startLine = thread.startLine ?? thread.line;
|
|
9
|
+
return {
|
|
10
|
+
startLine,
|
|
11
|
+
endLine: thread.line,
|
|
12
|
+
lines: parsed.lines,
|
|
13
|
+
author: thread.author,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* GitHub's "Commit suggestion" UI button treats the first ```suggestion fenced
|
|
5
5
|
* block in a review comment as a replacement for the commented line range.
|
|
6
|
-
* This module extracts that block from the comment body
|
|
7
|
-
* parsed suggestion to a file's contents.
|
|
6
|
+
* This module extracts that block from the comment body.
|
|
8
7
|
*
|
|
9
8
|
* There is no GitHub API for applying suggestions — tools that reproduce the
|
|
10
9
|
* button (this one included) must parse + commit themselves.
|
|
@@ -93,27 +92,3 @@ export function isCommittableSuggestion(parsed) {
|
|
|
93
92
|
const fenceRuns = (replacement.match(/`{3,}/g) ?? []).length;
|
|
94
93
|
return fenceRuns % 2 === 0;
|
|
95
94
|
}
|
|
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
|
-
}
|
package/bin/types/report.mjs
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { SAFE_SEGMENT } from "./path-segment.mjs";
|
|
6
|
+
const execFile = promisify(execFileCb);
|
|
7
|
+
/** Returns the absolute path to the current git worktree root. Throws outside a git repo. */
|
|
8
|
+
export async function getWorktreeRoot() {
|
|
9
|
+
const { stdout } = await execFile("git", ["rev-parse", "--show-toplevel"]);
|
|
10
|
+
return stdout.trim();
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Returns a filesystem-safe key for the current worktree, unique across
|
|
14
|
+
* worktrees of the same repo. Format: `<basename>-<sha8>` where sha8 is
|
|
15
|
+
* the first 8 hex chars of sha256(toplevel). Falls back to sha8-only if
|
|
16
|
+
* the basename contains characters outside SAFE_SEGMENT.
|
|
17
|
+
*/
|
|
18
|
+
export async function getWorktreeKey() {
|
|
19
|
+
const root = await getWorktreeRoot();
|
|
20
|
+
const sha8 = createHash("sha256").update(root).digest("hex").slice(0, 8);
|
|
21
|
+
const base = basename(root);
|
|
22
|
+
return SAFE_SEGMENT.test(base) ? `${base}-${sha8}` : sha8;
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/node": "^25.6.0",
|
|
27
|
-
"oxfmt": "^0.
|
|
27
|
+
"oxfmt": "^0.46.0",
|
|
28
28
|
"oxlint": "^1.60.0",
|
|
29
29
|
"typescript": "^6.0.3",
|
|
30
30
|
"vitest": "^4.1.4",
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { applyStallGuard } from "./stall.mjs";
|
|
2
|
-
export async function buildRerunCiResult(transientChecks, base, prNumber, stallKey, stallTimeoutSeconds, headSha, report, reviewSummaryIds) {
|
|
3
|
-
const runMap = new Map();
|
|
4
|
-
for (const c of transientChecks) {
|
|
5
|
-
if (c.runId === null)
|
|
6
|
-
continue;
|
|
7
|
-
const existing = runMap.get(c.runId);
|
|
8
|
-
if (existing) {
|
|
9
|
-
existing.checkNames.push(c.name);
|
|
10
|
-
}
|
|
11
|
-
else {
|
|
12
|
-
runMap.set(c.runId, {
|
|
13
|
-
runId: c.runId,
|
|
14
|
-
checkNames: [c.name],
|
|
15
|
-
failureKind: c.failureKind,
|
|
16
|
-
workflowName: c.workflowName,
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
const reran = [...runMap.values()];
|
|
21
|
-
const runSummaries = reran.map(({ runId, checkNames, failureKind, workflowName }) => {
|
|
22
|
-
const prefix = workflowName ? `${workflowName} › ` : "";
|
|
23
|
-
return `${runId} (${prefix}${checkNames.join(", ")} — ${failureKind})`;
|
|
24
|
-
});
|
|
25
|
-
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
|
|
26
|
-
...base,
|
|
27
|
-
action: "rerun_ci",
|
|
28
|
-
reran,
|
|
29
|
-
log: `RERUN NEEDED — ${reran.length} CI run${reran.length === 1 ? "" : "s"}: ${runSummaries.join(", ")}`,
|
|
30
|
-
}, report, reviewSummaryIds);
|
|
31
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
query MultiPrStatus($owner: String!, $repo: String!, $pr: Int!) {
|
|
2
|
-
repository(owner: $owner, name: $repo) {
|
|
3
|
-
pullRequest(number: $pr) {
|
|
4
|
-
number
|
|
5
|
-
state
|
|
6
|
-
isDraft
|
|
7
|
-
title
|
|
8
|
-
mergeStateStatus
|
|
9
|
-
reviewDecision
|
|
10
|
-
# totalCount lets callers detect truncation; pageInfo enables backward pagination.
|
|
11
|
-
reviewThreads(last: 100) {
|
|
12
|
-
totalCount
|
|
13
|
-
pageInfo {
|
|
14
|
-
hasPreviousPage
|
|
15
|
-
startCursor
|
|
16
|
-
}
|
|
17
|
-
nodes {
|
|
18
|
-
isResolved
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
commits(last: 1) {
|
|
22
|
-
nodes {
|
|
23
|
-
commit {
|
|
24
|
-
statusCheckRollup {
|
|
25
|
-
state
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|