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.
Files changed (52) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +2 -2
  3. package/bin/checks/triage.mjs +32 -40
  4. package/bin/cli/fence.mjs +4 -0
  5. package/bin/cli/fix-formatter.mjs +31 -25
  6. package/bin/cli/formatters.mjs +33 -51
  7. package/bin/cli/handlers.mjs +1 -1
  8. package/bin/cli/iterate-formatter.mjs +20 -35
  9. package/bin/cli/iterate-lean.mjs +3 -3
  10. package/bin/cli/list-formatters.mjs +32 -0
  11. package/bin/cli/suggestion-renderer.mjs +31 -0
  12. package/bin/cli-parser.iterate-fixtures.mjs +2 -2
  13. package/bin/cli-parser.mjs +27 -3
  14. package/bin/commands/check.mjs +7 -5
  15. package/bin/commands/commit-suggestion.mjs +12 -10
  16. package/bin/commands/iterate/classify.mjs +10 -32
  17. package/bin/commands/iterate/escalate.mjs +3 -12
  18. package/bin/commands/iterate/fix-code.mjs +7 -11
  19. package/bin/commands/iterate/index.mjs +2 -1
  20. package/bin/commands/iterate/render.mjs +30 -21
  21. package/bin/commands/log-file.mjs +7 -0
  22. package/bin/commands/monitor.mjs +7 -4
  23. package/bin/commands/ready-delay.mjs +2 -2
  24. package/bin/commands/resolve-instructions.mjs +5 -2
  25. package/bin/commands/resolve.mjs +1 -20
  26. package/bin/commands/status.mjs +40 -28
  27. package/bin/comments/resolve.mjs +75 -65
  28. package/bin/config.json +2 -2
  29. package/bin/github/batch-parsers.mjs +2 -0
  30. package/bin/github/client.mjs +11 -32
  31. package/bin/github/gql/batch-pr.gql +4 -0
  32. package/bin/github/gql/get-pr-head-sha.gql +7 -0
  33. package/bin/github/http.mjs +166 -11
  34. package/bin/github/queries.mjs +7 -11
  35. package/bin/log/log-file.mjs +88 -0
  36. package/bin/log/session.mjs +100 -0
  37. package/bin/log/setup.mjs +54 -0
  38. package/bin/reporters/agent.mjs +14 -1
  39. package/bin/reporters/text.mjs +82 -102
  40. package/bin/state/base.mjs +5 -0
  41. package/bin/state/fix-attempts.mjs +2 -2
  42. package/bin/state/iterate-stall.mjs +2 -2
  43. package/bin/state/seen-comments.mjs +2 -2
  44. package/bin/suggestions/extract.mjs +15 -0
  45. package/bin/suggestions/parse.mjs +1 -26
  46. package/bin/util/markdown.mjs +7 -0
  47. package/bin/util/worktree.mjs +23 -0
  48. package/package.json +6 -5
  49. package/bin/github/gql/dismiss-review.gql +0 -7
  50. package/bin/github/gql/minimize-comment.gql +0 -7
  51. package/bin/github/gql/multi-pr-status.gql +0 -32
  52. 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
+ }
@@ -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
- return { id: t.id, path: t.path, line: t.line, author: t.author, body: t.body, url: t.url };
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 };
@@ -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 parts = [];
4
- parts.push(`\nPR #${report.pr} — ${report.repo}`);
5
- parts.push(`Status: ${report.status}`);
6
- parts.push(`Base: ${report.baseBranch}`);
7
- parts.push("");
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
- parts.push("## Merge Status");
10
- parts.push("");
11
- parts.push(`${ms.status}`);
12
- parts.push(` mergeStateStatus: ${ms.mergeStateStatus}`);
13
- parts.push(` mergeable: ${ms.mergeable}`);
14
- parts.push(` reviewDecision: ${ms.reviewDecision ?? "(none)"}`);
15
- parts.push(` isDraft: ${ms.isDraft}`);
16
- parts.push(` copilotReviewInProgress: ${ms.copilotReviewInProgress}`);
17
- parts.push("");
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
- parts.push("## CI Checks");
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
- parts.push(`### Failed (${failing.length})`);
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
- parts.push(`- ${prefix}${c.name}: ${c.conclusion ?? c.status}`);
29
+ lines.push(`- ${prefix}${c.name}: ${c.conclusion ?? c.status}`);
31
30
  if (triaged.failedStep) {
32
- parts.push(` failed step: ${triaged.failedStep}`);
31
+ lines.push(` failed step: ${triaged.failedStep}`);
33
32
  }
34
33
  if (triaged.summary) {
35
- parts.push(` summary: ${triaged.summary}`);
34
+ lines.push(` summary: ${triaged.summary}`);
36
35
  }
37
36
  }
38
- parts.push("");
37
+ ciSubsections.push(lines.join("\n"));
39
38
  }
40
39
  if (inProgress.length > 0) {
41
- parts.push(`### In Progress (${inProgress.length})`);
42
- parts.push("");
40
+ const lines = [`### In Progress (${inProgress.length})`, ""];
43
41
  for (const c of inProgress) {
44
- parts.push(`- ${c.name}: ${c.status}`);
42
+ lines.push(`- ${c.name}: ${c.status}`);
45
43
  }
46
- parts.push("");
44
+ ciSubsections.push(lines.join("\n"));
47
45
  }
48
46
  if (skipped.length > 0) {
49
- parts.push(`### Skipped (${skipped.length}): ${skipped.map((c) => c.name).join(", ")}`);
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
- parts.push(`### Filtered non-PR-trigger (${report.checks.filtered.length}): ${report.checks.filtered.map((c) => c.name).join(", ")}`);
54
- parts.push("");
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
- 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.");
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
- parts.push(" Note: one or more of these filtered checks may be a required status check blocking merge.");
58
+ lines.push("> Note: one or more of these filtered checks may be a required status check blocking merge.");
60
59
  }
61
- parts.push("");
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
- parts.push("## Review Threads");
67
- parts.push("");
67
+ const subparts = [];
68
68
  if (autoResolved.length > 0) {
69
- parts.push(`Auto-resolved outdated (${autoResolved.length}):`);
69
+ const lines = [`Auto-resolved outdated (${autoResolved.length}):`];
70
70
  for (const t of autoResolved) {
71
- parts.push(`- threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author})`);
71
+ lines.push(renderThreadBullet(t));
72
72
  }
73
- parts.push("");
73
+ subparts.push(lines.join("\n"));
74
74
  }
75
75
  if (autoResolveErrors.length > 0) {
76
- parts.push(`Auto-resolve errors (${autoResolveErrors.length}):`);
76
+ const lines = [`Auto-resolve errors (${autoResolveErrors.length}):`];
77
77
  for (const e of autoResolveErrors) {
78
- parts.push(`- ${e}`);
78
+ lines.push(`- ${e}`);
79
79
  }
80
- parts.push("");
80
+ subparts.push(lines.join("\n"));
81
81
  }
82
82
  if (actionableThreads.length > 0) {
83
- parts.push(`### Actionable (${actionableThreads.length})`);
84
- parts.push("");
83
+ const lines = [`### Actionable (${actionableThreads.length})`, ""];
85
84
  for (const t of actionableThreads) {
86
- const label = t.path ? `${t.path}:${t.line ?? "?"}` : "(general)";
87
- parts.push(`- threadId=${t.id} ${label} (@${t.author})`);
88
- parts.push(` ${firstLine(t.body)}`);
85
+ lines.push(renderThreadBullet(t));
89
86
  }
90
- parts.push("");
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
- parts.push("## PR Comments");
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
- parts.push(`- commentId=${c.id} (@${c.author}): ${firstLine(c.body)}`);
96
+ lines.push(renderCommentBullet(c));
101
97
  }
102
- parts.push("");
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
- parts.push("## First-look items");
131
- parts.push("");
107
+ const lines = [
108
+ `## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`,
109
+ "",
110
+ ];
132
111
  for (const t of firstLookThreads) {
133
- const statusTag = t.autoResolved
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
- parts.push(`- commentId=${c.id} (@${c.author}) [status: minimized]`);
142
- parts.push(` ${firstLine(c.body)}`);
115
+ lines.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
143
116
  }
144
- parts.push("");
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
- parts.push(summaryLine);
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.forEach((step, i) => {
161
- parts.push(`${i + 1}. ${step}`);
162
- });
163
- return parts.join("\n");
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 firstLine(text) {
166
- return (text.split("\n")[0] ?? "").trim().slice(0, 120);
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
  }
@@ -0,0 +1,5 @@
1
+ import { join } from "node:path";
2
+ import { tmpdir } from "node:os";
3
+ export function resolveStateBase() {
4
+ return process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
5
+ }
@@ -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 = process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
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 = process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
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 = process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
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, plus applies a
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,7 @@
1
+ export function joinSections(sections) {
2
+ return sections
3
+ .filter((s) => s != null)
4
+ .map((s) => s.replace(/^[\r\n]+|[\r\n]+$/g, ""))
5
+ .filter((s) => s !== "")
6
+ .join("\n\n");
7
+ }
@@ -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.9.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
- "oxfmt": "^0.45.0",
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-hooks.mjs && node scripts/install-plugin-symlink.mjs && npm run build",
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/",
@@ -1,7 +0,0 @@
1
- mutation DismissReview($reviewId: ID!, $message: String!) {
2
- dismissPullRequestReview(input: { pullRequestReviewId: $reviewId, message: $message }) {
3
- pullRequestReview {
4
- state
5
- }
6
- }
7
- }
@@ -1,7 +0,0 @@
1
- mutation MinimizeComment($commentId: ID!, $classifier: ReportedContentClassifiers!) {
2
- minimizeComment(input: { subjectId: $commentId, classifier: $classifier }) {
3
- minimizedComment {
4
- isMinimized
5
- }
6
- }
7
- }