pr-shepherd 0.9.0 → 0.10.1
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 +2 -2
- package/bin/checks/triage.mjs +32 -40
- package/bin/cli/fence.mjs +4 -0
- package/bin/cli/fix-formatter.mjs +31 -25
- package/bin/cli/formatters.mjs +33 -51
- package/bin/cli/handlers.mjs +1 -1
- package/bin/cli/iterate-formatter.mjs +20 -35
- package/bin/cli/iterate-lean.mjs +3 -3
- package/bin/cli/list-formatters.mjs +32 -0
- package/bin/cli/suggestion-renderer.mjs +31 -0
- package/bin/cli-parser.iterate-fixtures.mjs +2 -2
- package/bin/cli-parser.mjs +27 -3
- package/bin/commands/check.mjs +7 -5
- package/bin/commands/commit-suggestion.mjs +12 -10
- package/bin/commands/iterate/classify.mjs +10 -32
- package/bin/commands/iterate/escalate.mjs +3 -12
- package/bin/commands/iterate/fix-code.mjs +7 -11
- package/bin/commands/iterate/index.mjs +2 -1
- package/bin/commands/iterate/render.mjs +30 -21
- 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 +5 -2
- package/bin/commands/resolve.mjs +1 -20
- package/bin/commands/status.mjs +40 -28
- package/bin/comments/resolve.mjs +75 -65
- package/bin/config.json +2 -2
- package/bin/github/batch-parsers.mjs +2 -0
- package/bin/github/client.mjs +11 -32
- package/bin/github/gql/batch-pr.gql +4 -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 +14 -1
- package/bin/reporters/text.mjs +82 -102
- 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 +2 -2
- package/bin/suggestions/extract.mjs +15 -0
- package/bin/suggestions/parse.mjs +1 -26
- package/bin/util/markdown.mjs +7 -0
- package/bin/util/worktree.mjs +23 -0
- package/package.json +6 -5
- 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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
function readVersion() {
|
|
4
|
+
const pkgUrl = new URL("../../package.json", import.meta.url);
|
|
5
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(pkgUrl), "utf8"));
|
|
6
|
+
return pkg.version;
|
|
7
|
+
}
|
|
8
|
+
/** Builds the session header markdown block. */
|
|
9
|
+
export function buildSessionHeader(argv) {
|
|
10
|
+
const ts = new Date().toISOString();
|
|
11
|
+
const cmd = argv.slice(2).join(" ") || "(no args)";
|
|
12
|
+
const markdown = `## ${ts} — pr-shepherd ${cmd}\n\n` + `pid: ${process.pid} · version: ${readVersion()}\n\n`;
|
|
13
|
+
return { markdown };
|
|
14
|
+
}
|
|
15
|
+
const _maxBodyRaw = Number(process.env["PR_SHEPHERD_LOG_MAX_BODY"]);
|
|
16
|
+
const MAX_BODY = Number.isFinite(_maxBodyRaw) && _maxBodyRaw > 0 ? _maxBodyRaw : 256 * 1024;
|
|
17
|
+
function truncate(s) {
|
|
18
|
+
if (s.length <= MAX_BODY)
|
|
19
|
+
return s;
|
|
20
|
+
return `${s.slice(0, MAX_BODY)}\n...[truncated ${s.length - MAX_BODY} characters]`;
|
|
21
|
+
}
|
|
22
|
+
function fenceBody(body, lang) {
|
|
23
|
+
const raw = typeof body === "string" ? body : JSON.stringify(body);
|
|
24
|
+
return `\`\`\`${lang}\n${truncate(raw)}\n\`\`\`\n`;
|
|
25
|
+
}
|
|
26
|
+
function extractOperationName(query) {
|
|
27
|
+
const match = /^\s*(?:query|mutation|subscription)\s+(\w+)/m.exec(query);
|
|
28
|
+
return match?.[1] ?? "(anonymous)";
|
|
29
|
+
}
|
|
30
|
+
export function formatRequestEntry(entry) {
|
|
31
|
+
const ts = new Date().toISOString();
|
|
32
|
+
const label = entry.kind === "GraphQL"
|
|
33
|
+
? `GraphQL request — POST ${entry.url}`
|
|
34
|
+
: entry.kind === "restText"
|
|
35
|
+
? `restText request — GET ${entry.url}`
|
|
36
|
+
: `REST request — ${entry.method} ${entry.url}`;
|
|
37
|
+
let out = `### #${entry.n} ${label} · ${ts}\n\n`;
|
|
38
|
+
if (entry.kind === "restText") {
|
|
39
|
+
out += `(body omitted: log artifact)\n\n`;
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
if (entry.body !== undefined && entry.kind === "GraphQL") {
|
|
43
|
+
const { query, variables } = entry.body;
|
|
44
|
+
out += `operation: \`${extractOperationName(query)}\`\n`;
|
|
45
|
+
if (variables && Object.keys(variables).length > 0) {
|
|
46
|
+
out += `variables:\n${fenceBody(variables, "json")}`;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
// For dynamic documents (e.g. BulkApply) the IDs are inlined as aliases.
|
|
50
|
+
// Count the aliases so the log shows how many operations were batched.
|
|
51
|
+
const aliasCount = (query.match(/^\s+[_A-Za-z]\w*:/gm) ?? []).length;
|
|
52
|
+
if (aliasCount > 0)
|
|
53
|
+
out += `aliases: ${aliasCount}\n`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (entry.body !== undefined) {
|
|
57
|
+
out += fenceBody(entry.body, "json");
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
out += `(no body)\n`;
|
|
61
|
+
}
|
|
62
|
+
return out + "\n";
|
|
63
|
+
}
|
|
64
|
+
export function formatResponseEntry(entry) {
|
|
65
|
+
const ts = new Date().toISOString();
|
|
66
|
+
const attempt = entry.attempt !== undefined ? ` (attempt ${entry.attempt}/2 after 401)` : "";
|
|
67
|
+
const label = entry.kind === "GraphQL"
|
|
68
|
+
? `GraphQL response — ${entry.status}${attempt} · ${entry.durationMs}ms`
|
|
69
|
+
: entry.kind === "restText"
|
|
70
|
+
? `restText response — ${entry.status}${attempt} · ${entry.durationMs}ms`
|
|
71
|
+
: `REST response — ${entry.status}${attempt} · ${entry.durationMs}ms`;
|
|
72
|
+
let out = `### #${entry.n} ${label} · ${ts}\n\n`;
|
|
73
|
+
if (entry.kind === "restText") {
|
|
74
|
+
if (entry.contentLength !== undefined) {
|
|
75
|
+
out += `content-length: ${entry.contentLength} bytes (body not logged)\n\n`;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
out += `(body not logged)\n\n`;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
if (entry.contentType !== undefined) {
|
|
83
|
+
out += `content-type: ${entry.contentType}`;
|
|
84
|
+
if (entry.contentLength !== undefined)
|
|
85
|
+
out += ` · ${entry.contentLength} bytes`;
|
|
86
|
+
out += "\n";
|
|
87
|
+
}
|
|
88
|
+
if (entry.body !== undefined) {
|
|
89
|
+
out += fenceBody(entry.body, "json");
|
|
90
|
+
}
|
|
91
|
+
else if (entry.textBody !== undefined) {
|
|
92
|
+
out += fenceBody(entry.textBody, "");
|
|
93
|
+
}
|
|
94
|
+
return out + "\n";
|
|
95
|
+
}
|
|
96
|
+
export function formatOutputEntry(text, format) {
|
|
97
|
+
const ts = new Date().toISOString();
|
|
98
|
+
const lang = format === "json" ? "json" : "";
|
|
99
|
+
return `### Output (${format}) · ${ts}\n\n${fenceBody(text.trimEnd(), lang)}\n`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { initLog, appendEntry } from "./log-file.mjs";
|
|
2
|
+
import { buildSessionHeader, formatOutputEntry } from "./session.mjs";
|
|
3
|
+
import { getRepoInfo } from "../github/client.mjs";
|
|
4
|
+
let _done = false;
|
|
5
|
+
function detectFormat(argv) {
|
|
6
|
+
for (let i = 0; i < argv.length; i++) {
|
|
7
|
+
if (argv[i] === "--format=json")
|
|
8
|
+
return "json";
|
|
9
|
+
if (argv[i] === "--format" && argv[i + 1] === "json")
|
|
10
|
+
return "json";
|
|
11
|
+
}
|
|
12
|
+
return "text";
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Initialize the per-worktree log, write the session header, and install a
|
|
16
|
+
* stdout tee that routes all CLI output to the log. No-op after the first call.
|
|
17
|
+
* Silently skips logging when not in a git repo or on any other error.
|
|
18
|
+
*/
|
|
19
|
+
export async function setupLog(argv) {
|
|
20
|
+
if (_done)
|
|
21
|
+
return;
|
|
22
|
+
_done = true;
|
|
23
|
+
try {
|
|
24
|
+
const { owner, name } = await getRepoInfo();
|
|
25
|
+
const log = await initLog({ owner, repo: name });
|
|
26
|
+
if (!log)
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const { markdown: header } = buildSessionHeader(argv);
|
|
34
|
+
appendEntry(header);
|
|
35
|
+
const format = detectFormat(argv);
|
|
36
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
37
|
+
process.stdout.write = (chunk, encodingOrCb, cb) => {
|
|
38
|
+
try {
|
|
39
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
40
|
+
if (text.length > 0)
|
|
41
|
+
appendEntry(formatOutputEntry(text, format));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Best-effort: logging must never interfere with CLI output.
|
|
45
|
+
}
|
|
46
|
+
return typeof encodingOrCb === "function"
|
|
47
|
+
? origWrite(chunk, encodingOrCb)
|
|
48
|
+
: origWrite(chunk, encodingOrCb, cb);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Best-effort: logging setup must never prevent the CLI from running.
|
|
53
|
+
}
|
|
54
|
+
}
|
package/bin/reporters/agent.mjs
CHANGED
|
@@ -7,8 +7,21 @@
|
|
|
7
7
|
* detailsUrl is preserved in AgentCheck as a fallback for external status checks.
|
|
8
8
|
* The original domain types are preserved for check command output.
|
|
9
9
|
*/
|
|
10
|
+
import { extractSuggestion } from "../suggestions/extract.mjs";
|
|
10
11
|
export function toAgentThread(t) {
|
|
11
|
-
|
|
12
|
+
const suggestion = extractSuggestion(t) ?? undefined;
|
|
13
|
+
return {
|
|
14
|
+
id: t.id,
|
|
15
|
+
path: t.path,
|
|
16
|
+
line: t.line,
|
|
17
|
+
...(t.line !== null &&
|
|
18
|
+
t.startLine !== null &&
|
|
19
|
+
t.startLine !== t.line && { startLine: t.startLine }),
|
|
20
|
+
author: t.author,
|
|
21
|
+
body: t.body,
|
|
22
|
+
url: t.url,
|
|
23
|
+
...(suggestion !== undefined && { suggestion }),
|
|
24
|
+
};
|
|
12
25
|
}
|
|
13
26
|
export function toAgentComment(c) {
|
|
14
27
|
return { id: c.id, author: c.author, body: c.body, url: c.url };
|
package/bin/reporters/text.mjs
CHANGED
|
@@ -1,167 +1,147 @@
|
|
|
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
|
-
|
|
5
|
+
const header = [
|
|
6
|
+
`# PR #${report.pr} [CHECK] — ${report.repo}`,
|
|
7
|
+
`Status: ${report.status}`,
|
|
8
|
+
`Base: ${report.baseBranch}`,
|
|
9
|
+
].join("\n");
|
|
8
10
|
const ms = report.mergeStatus;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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");
|
|
18
21
|
const { passing, failing, inProgress, skipped } = report.checks;
|
|
19
22
|
const total = passing.length + failing.length + inProgress.length + skipped.length;
|
|
20
|
-
|
|
21
|
-
parts.push("");
|
|
22
|
-
parts.push(`${passing.length}/${total} passed`);
|
|
23
|
-
parts.push("");
|
|
23
|
+
const ciSubsections = [`${passing.length}/${total} passed`];
|
|
24
24
|
if (failing.length > 0) {
|
|
25
|
-
|
|
26
|
-
parts.push("");
|
|
25
|
+
const lines = [`### Failed (${failing.length})`, ""];
|
|
27
26
|
for (const c of failing) {
|
|
28
27
|
const triaged = c;
|
|
29
28
|
const prefix = triaged.workflowName ? `${triaged.workflowName} › ` : "";
|
|
30
|
-
|
|
29
|
+
lines.push(`- ${prefix}${c.name}: ${c.conclusion ?? c.status}`);
|
|
31
30
|
if (triaged.failedStep) {
|
|
32
|
-
|
|
31
|
+
lines.push(` failed step: ${triaged.failedStep}`);
|
|
33
32
|
}
|
|
34
33
|
if (triaged.summary) {
|
|
35
|
-
|
|
34
|
+
lines.push(` summary: ${triaged.summary}`);
|
|
36
35
|
}
|
|
37
36
|
}
|
|
38
|
-
|
|
37
|
+
ciSubsections.push(lines.join("\n"));
|
|
39
38
|
}
|
|
40
39
|
if (inProgress.length > 0) {
|
|
41
|
-
|
|
42
|
-
parts.push("");
|
|
40
|
+
const lines = [`### In Progress (${inProgress.length})`, ""];
|
|
43
41
|
for (const c of inProgress) {
|
|
44
|
-
|
|
42
|
+
lines.push(`- ${c.name}: ${c.status}`);
|
|
45
43
|
}
|
|
46
|
-
|
|
44
|
+
ciSubsections.push(lines.join("\n"));
|
|
47
45
|
}
|
|
48
46
|
if (skipped.length > 0) {
|
|
49
|
-
|
|
50
|
-
parts.push("");
|
|
47
|
+
ciSubsections.push(`### Skipped (${skipped.length}): ${skipped.map((c) => c.name).join(", ")}`);
|
|
51
48
|
}
|
|
52
49
|
if (report.checks.filtered.length > 0) {
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
const lines = [
|
|
51
|
+
`### Filtered non-PR-trigger (${report.checks.filtered.length}): ${report.checks.filtered.map((c) => c.name).join(", ")}`,
|
|
52
|
+
"",
|
|
53
|
+
];
|
|
55
54
|
if (report.checks.blockedByFilteredCheck) {
|
|
56
|
-
|
|
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.");
|
|
57
56
|
}
|
|
58
57
|
else if (report.mergeStatus.status === "BLOCKED") {
|
|
59
|
-
|
|
58
|
+
lines.push("> Note: one or more of these filtered checks may be a required status check blocking merge.");
|
|
60
59
|
}
|
|
61
|
-
|
|
60
|
+
ciSubsections.push(lines.join("\n"));
|
|
62
61
|
}
|
|
62
|
+
const ciSection = `## CI Checks\n\n${ciSubsections.join("\n\n")}`;
|
|
63
63
|
const { actionable: actionableThreads, autoResolved, autoResolveErrors, firstLook: firstLookThreads, } = report.threads;
|
|
64
64
|
const hasThreadSection = autoResolved.length > 0 || autoResolveErrors.length > 0 || actionableThreads.length > 0;
|
|
65
|
+
let threadsSection = null;
|
|
65
66
|
if (hasThreadSection) {
|
|
66
|
-
|
|
67
|
-
parts.push("");
|
|
67
|
+
const subparts = [];
|
|
68
68
|
if (autoResolved.length > 0) {
|
|
69
|
-
|
|
69
|
+
const lines = [`Auto-resolved outdated (${autoResolved.length}):`];
|
|
70
70
|
for (const t of autoResolved) {
|
|
71
|
-
|
|
71
|
+
lines.push(renderThreadBullet(t));
|
|
72
72
|
}
|
|
73
|
-
|
|
73
|
+
subparts.push(lines.join("\n"));
|
|
74
74
|
}
|
|
75
75
|
if (autoResolveErrors.length > 0) {
|
|
76
|
-
|
|
76
|
+
const lines = [`Auto-resolve errors (${autoResolveErrors.length}):`];
|
|
77
77
|
for (const e of autoResolveErrors) {
|
|
78
|
-
|
|
78
|
+
lines.push(`- ${e}`);
|
|
79
79
|
}
|
|
80
|
-
|
|
80
|
+
subparts.push(lines.join("\n"));
|
|
81
81
|
}
|
|
82
82
|
if (actionableThreads.length > 0) {
|
|
83
|
-
|
|
84
|
-
parts.push("");
|
|
83
|
+
const lines = [`### Actionable (${actionableThreads.length})`, ""];
|
|
85
84
|
for (const t of actionableThreads) {
|
|
86
|
-
|
|
87
|
-
parts.push(`- threadId=${t.id} ${label} (@${t.author})`);
|
|
88
|
-
parts.push(` ${firstLine(t.body)}`);
|
|
85
|
+
lines.push(renderThreadBullet(t));
|
|
89
86
|
}
|
|
90
|
-
|
|
87
|
+
subparts.push(lines.join("\n"));
|
|
91
88
|
}
|
|
89
|
+
threadsSection = `## Review Threads\n\n${subparts.join("\n\n")}`;
|
|
92
90
|
}
|
|
93
91
|
const { actionable: actionableComments, firstLook: firstLookComments } = report.comments;
|
|
92
|
+
let commentsSection = null;
|
|
94
93
|
if (actionableComments.length > 0) {
|
|
95
|
-
|
|
96
|
-
parts.push("");
|
|
97
|
-
parts.push(`### Actionable (${actionableComments.length})`);
|
|
98
|
-
parts.push("");
|
|
94
|
+
const lines = [`### Actionable (${actionableComments.length})`, ""];
|
|
99
95
|
for (const c of actionableComments) {
|
|
100
|
-
|
|
96
|
+
lines.push(renderCommentBullet(c));
|
|
101
97
|
}
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
if (report.changesRequestedReviews.length > 0) {
|
|
105
|
-
parts.push("## CHANGES_REQUESTED Reviews");
|
|
106
|
-
parts.push("");
|
|
107
|
-
for (const r of report.changesRequestedReviews) {
|
|
108
|
-
parts.push(`- reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
|
|
109
|
-
}
|
|
110
|
-
parts.push("");
|
|
111
|
-
}
|
|
112
|
-
if (report.reviewSummaries.length > 0) {
|
|
113
|
-
parts.push("## Review Summaries");
|
|
114
|
-
parts.push("");
|
|
115
|
-
for (const r of report.reviewSummaries) {
|
|
116
|
-
parts.push(`- reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
|
|
117
|
-
}
|
|
118
|
-
parts.push("");
|
|
119
|
-
}
|
|
120
|
-
if (report.approvedReviews.length > 0) {
|
|
121
|
-
parts.push("## Approved Reviews");
|
|
122
|
-
parts.push("");
|
|
123
|
-
for (const r of report.approvedReviews) {
|
|
124
|
-
parts.push(`- reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
|
|
125
|
-
}
|
|
126
|
-
parts.push("");
|
|
98
|
+
commentsSection = `## PR Comments\n\n${lines.join("\n")}`;
|
|
127
99
|
}
|
|
100
|
+
const changesRequestedSection = reviewListSection("CHANGES_REQUESTED Reviews", report.changesRequestedReviews);
|
|
101
|
+
const allSummaries = [...report.firstLookSummaries, ...report.reviewSummaries];
|
|
102
|
+
const reviewSummariesSection = reviewListSection("Review Summaries", allSummaries);
|
|
103
|
+
const approvedReviewsSection = reviewListSection("Approved Reviews", report.approvedReviews);
|
|
128
104
|
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
105
|
+
let firstLookSection = null;
|
|
129
106
|
if (firstLookTotal > 0) {
|
|
130
|
-
|
|
131
|
-
|
|
107
|
+
const lines = [
|
|
108
|
+
`## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`,
|
|
109
|
+
"",
|
|
110
|
+
];
|
|
132
111
|
for (const t of firstLookThreads) {
|
|
133
|
-
|
|
134
|
-
? `[status: outdated, auto-resolved]`
|
|
135
|
-
: `[status: ${t.firstLookStatus}]`;
|
|
136
|
-
const loc = t.path ? `${t.path}:${t.line ?? "?"}` : "(no location)";
|
|
137
|
-
parts.push(`- threadId=${t.id} ${loc} (@${t.author}) ${statusTag}`);
|
|
138
|
-
parts.push(` ${firstLine(t.body)}`);
|
|
112
|
+
lines.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
|
|
139
113
|
}
|
|
140
114
|
for (const c of firstLookComments) {
|
|
141
|
-
|
|
142
|
-
parts.push(` ${firstLine(c.body)}`);
|
|
115
|
+
lines.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
|
|
143
116
|
}
|
|
144
|
-
|
|
117
|
+
firstLookSection = lines.join("\n");
|
|
145
118
|
}
|
|
146
119
|
const totalActionable = actionableThreads.length + actionableComments.length + report.changesRequestedReviews.length;
|
|
147
|
-
parts.push("## Summary");
|
|
148
|
-
parts.push("");
|
|
149
120
|
const counts = [];
|
|
150
121
|
if (totalActionable > 0)
|
|
151
122
|
counts.push(`${totalActionable} actionable`);
|
|
152
123
|
if (firstLookTotal > 0)
|
|
153
124
|
counts.push(`${firstLookTotal} first-look`);
|
|
154
125
|
const summaryLine = counts.join(", ") || "0 actionable — all threads resolved/minimized";
|
|
155
|
-
|
|
156
|
-
parts.push("");
|
|
157
|
-
parts.push("## Instructions");
|
|
158
|
-
parts.push("");
|
|
126
|
+
const summarySection = `## Summary\n\n${summaryLine}`;
|
|
159
127
|
const instructions = buildCheckInstructions(report);
|
|
160
|
-
instructions.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
128
|
+
const instructionsSection = `## Instructions\n\n${instructions.map((step, i) => `${i + 1}. ${step}`).join("\n")}`;
|
|
129
|
+
return joinSections([
|
|
130
|
+
header,
|
|
131
|
+
mergeStatusSection,
|
|
132
|
+
ciSection,
|
|
133
|
+
threadsSection,
|
|
134
|
+
commentsSection,
|
|
135
|
+
changesRequestedSection,
|
|
136
|
+
reviewSummariesSection,
|
|
137
|
+
approvedReviewsSection,
|
|
138
|
+
firstLookSection,
|
|
139
|
+
summarySection,
|
|
140
|
+
instructionsSection,
|
|
141
|
+
]);
|
|
164
142
|
}
|
|
165
|
-
function
|
|
166
|
-
|
|
143
|
+
function reviewListSection(heading, items) {
|
|
144
|
+
if (items.length === 0)
|
|
145
|
+
return null;
|
|
146
|
+
return `## ${heading}\n\n${items.map((r) => renderReviewBullet(r, { includeBody: true })).join("\n")}`;
|
|
167
147
|
}
|
|
@@ -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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, access, readdir } from "node:fs/promises";
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
3
|
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
4
|
+
import { resolveStateBase } from "./base.mjs";
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
// Public API
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
@@ -65,7 +65,7 @@ function resolveDir(key) {
|
|
|
65
65
|
throw new Error(`Invalid state key segment "${field}": ${value}`);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
const base =
|
|
68
|
+
const base = resolveStateBase();
|
|
69
69
|
return join(base, `${key.owner}-${key.repo}`, String(key.pr), "seen");
|
|
70
70
|
}
|
|
71
71
|
function resolvePath(key, id) {
|
|
@@ -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
|
-
}
|
|
@@ -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.1",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
@@ -24,15 +24,16 @@
|
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/node": "^25.6.0",
|
|
27
|
-
"
|
|
27
|
+
"@vitest/coverage-v8": "^4.1.4",
|
|
28
|
+
"husky": "^9.1.7",
|
|
29
|
+
"oxfmt": "^0.46.0",
|
|
28
30
|
"oxlint": "^1.60.0",
|
|
29
31
|
"typescript": "^6.0.3",
|
|
30
|
-
"vitest": "^4.1.4"
|
|
31
|
-
"@vitest/coverage-v8": "^4.1.4"
|
|
32
|
+
"vitest": "^4.1.4"
|
|
32
33
|
},
|
|
33
34
|
"scripts": {
|
|
34
35
|
"build": "node scripts/build.mjs",
|
|
35
|
-
"prepare": "node scripts/install-
|
|
36
|
+
"prepare": "node scripts/install-husky.mjs && node scripts/install-plugin-symlink.mjs && npm run build",
|
|
36
37
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
37
38
|
"typecheck": "tsc --noEmit",
|
|
38
39
|
"lint": "oxlint src/ plugin/skills/",
|