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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +123 -21
  3. package/bin/checks/classify.mjs +2 -1
  4. package/bin/checks/triage.mjs +63 -61
  5. package/bin/cli/args.mjs +9 -3
  6. package/bin/cli/fence.mjs +4 -0
  7. package/bin/cli/fix-formatter.mjs +45 -19
  8. package/bin/cli/formatters.mjs +61 -44
  9. package/bin/cli/handlers.mjs +5 -4
  10. package/bin/cli/iterate-formatter.mjs +51 -45
  11. package/bin/cli/iterate-lean.mjs +95 -0
  12. package/bin/cli/list-formatters.mjs +32 -0
  13. package/bin/cli/suggestion-renderer.mjs +31 -0
  14. package/bin/cli-parser.iterate-fixtures.mjs +6 -5
  15. package/bin/cli-parser.mjs +27 -3
  16. package/bin/commands/check-status.mjs +7 -4
  17. package/bin/commands/check.mjs +39 -3
  18. package/bin/commands/commit-suggestion.mjs +12 -10
  19. package/bin/commands/iterate/classify.mjs +10 -54
  20. package/bin/commands/iterate/escalate.mjs +5 -14
  21. package/bin/commands/iterate/fix-code.mjs +15 -16
  22. package/bin/commands/iterate/helpers.mjs +8 -5
  23. package/bin/commands/iterate/index.mjs +18 -12
  24. package/bin/commands/iterate/render.mjs +28 -12
  25. package/bin/commands/iterate/stall.mjs +1 -1
  26. package/bin/commands/log-file.mjs +7 -0
  27. package/bin/commands/monitor.mjs +7 -4
  28. package/bin/commands/ready-delay.mjs +2 -2
  29. package/bin/commands/resolve-instructions.mjs +14 -5
  30. package/bin/commands/resolve.mjs +38 -37
  31. package/bin/commands/status.mjs +41 -29
  32. package/bin/comments/resolve.mjs +75 -65
  33. package/bin/config.json +4 -7
  34. package/bin/github/batch-parsers.mjs +4 -0
  35. package/bin/github/client.mjs +11 -32
  36. package/bin/github/gql/batch-pr.gql +6 -0
  37. package/bin/github/gql/get-pr-head-sha.gql +7 -0
  38. package/bin/github/http.mjs +166 -11
  39. package/bin/github/queries.mjs +7 -11
  40. package/bin/log/log-file.mjs +88 -0
  41. package/bin/log/session.mjs +100 -0
  42. package/bin/log/setup.mjs +54 -0
  43. package/bin/reporters/agent.mjs +21 -13
  44. package/bin/reporters/check-instructions.mjs +9 -14
  45. package/bin/reporters/text.mjs +93 -105
  46. package/bin/state/base.mjs +5 -0
  47. package/bin/state/fix-attempts.mjs +2 -2
  48. package/bin/state/iterate-stall.mjs +2 -2
  49. package/bin/state/seen-comments.mjs +76 -0
  50. package/bin/suggestions/extract.mjs +15 -0
  51. package/bin/suggestions/parse.mjs +1 -26
  52. package/bin/types/report.mjs +0 -1
  53. package/bin/util/markdown.mjs +7 -0
  54. package/bin/util/worktree.mjs +23 -0
  55. package/package.json +2 -2
  56. package/bin/commands/iterate/steps.mjs +0 -31
  57. package/bin/github/gql/dismiss-review.gql +0 -7
  58. package/bin/github/gql/minimize-comment.gql +0 -7
  59. package/bin/github/gql/multi-pr-status.gql +0 -32
  60. package/bin/github/gql/resolve-thread.gql +0 -7
@@ -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 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("");
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
- 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("");
20
- // CI checks
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
- parts.push("## CI Checks");
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
- parts.push(`### Failed (${failing.length})`);
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
- parts.push(`- ${prefix}${c.name}: ${c.conclusion ?? c.status}`);
29
+ lines.push(`- ${prefix}${c.name}: ${c.conclusion ?? c.status}`);
34
30
  if (triaged.failedStep) {
35
- parts.push(` failed step: ${triaged.failedStep}`);
31
+ lines.push(` failed step: ${triaged.failedStep}`);
36
32
  }
37
33
  if (triaged.summary) {
38
- parts.push(` summary: ${triaged.summary}`);
34
+ lines.push(` summary: ${triaged.summary}`);
39
35
  }
40
36
  }
41
- parts.push("");
37
+ ciSubsections.push(lines.join("\n"));
42
38
  }
43
39
  if (inProgress.length > 0) {
44
- parts.push(`### In Progress (${inProgress.length})`);
45
- parts.push("");
40
+ const lines = [`### In Progress (${inProgress.length})`, ""];
46
41
  for (const c of inProgress) {
47
- parts.push(`- ${c.name}: ${c.status}`);
42
+ lines.push(`- ${c.name}: ${c.status}`);
48
43
  }
49
- parts.push("");
44
+ ciSubsections.push(lines.join("\n"));
50
45
  }
51
46
  if (skipped.length > 0) {
52
- parts.push(`### Skipped (${skipped.length}): ${skipped.map((c) => c.name).join(", ")}`);
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
- parts.push(`### Filtered non-PR-trigger (${report.checks.filtered.length}): ${report.checks.filtered.map((c) => c.name).join(", ")}`);
57
- parts.push("");
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
- 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.");
60
56
  }
61
57
  else if (report.mergeStatus.status === "BLOCKED") {
62
- 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.");
63
59
  }
64
- parts.push("");
60
+ ciSubsections.push(lines.join("\n"));
65
61
  }
66
- // Review threads
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
- parts.push("## Review Threads");
71
- parts.push("");
67
+ const subparts = [];
72
68
  if (autoResolved.length > 0) {
73
- parts.push(`Auto-resolved outdated (${autoResolved.length}):`);
69
+ const lines = [`Auto-resolved outdated (${autoResolved.length}):`];
74
70
  for (const t of autoResolved) {
75
- parts.push(`- threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author})`);
71
+ lines.push(renderThreadBullet(t));
76
72
  }
77
- parts.push("");
73
+ subparts.push(lines.join("\n"));
78
74
  }
79
75
  if (autoResolveErrors.length > 0) {
80
- parts.push(`Auto-resolve errors (${autoResolveErrors.length}):`);
76
+ const lines = [`Auto-resolve errors (${autoResolveErrors.length}):`];
81
77
  for (const e of autoResolveErrors) {
82
- parts.push(`- ${e}`);
78
+ lines.push(`- ${e}`);
83
79
  }
84
- parts.push("");
80
+ subparts.push(lines.join("\n"));
85
81
  }
86
82
  if (actionableThreads.length > 0) {
87
- parts.push(`### Actionable (${actionableThreads.length})`);
88
- parts.push("");
83
+ const lines = [`### Actionable (${actionableThreads.length})`, ""];
89
84
  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)}`);
85
+ lines.push(renderThreadBullet(t));
93
86
  }
94
- parts.push("");
87
+ subparts.push(lines.join("\n"));
95
88
  }
89
+ threadsSection = `## Review Threads\n\n${subparts.join("\n\n")}`;
96
90
  }
97
- // PR comments
98
- const { actionable: actionableComments } = report.comments;
91
+ const { actionable: actionableComments, firstLook: firstLookComments } = report.comments;
92
+ let commentsSection = null;
99
93
  if (actionableComments.length > 0) {
100
- parts.push("## PR Comments");
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
- parts.push(`- commentId=${c.id} (@${c.author}): ${firstLine(c.body)}`);
96
+ lines.push(renderCommentBullet(c));
106
97
  }
107
- parts.push("");
98
+ commentsSection = `## PR Comments\n\n${lines.join("\n")}`;
108
99
  }
109
- // CHANGES_REQUESTED reviews
110
- if (report.changesRequestedReviews.length > 0) {
111
- parts.push("## CHANGES_REQUESTED Reviews");
112
- parts.push("");
113
- for (const r of report.changesRequestedReviews) {
114
- parts.push(`- reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
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
- 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)}`);
113
+ for (const c of firstLookComments) {
114
+ lines.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
133
115
  }
134
- parts.push("");
116
+ firstLookSection = lines.join("\n");
135
117
  }
136
- // Summary
137
118
  const totalActionable = actionableThreads.length + actionableComments.length + report.changesRequestedReviews.length;
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("");
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.forEach((step, i) => {
149
- parts.push(`${i + 1}. ${step}`);
150
- });
151
- return parts.join("\n");
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
- // Helpers
155
- // ---------------------------------------------------------------------------
156
- function firstLine(text) {
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
  }
@@ -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
  }
@@ -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, 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
- }
@@ -1,2 +1 @@
1
- // Shepherd-specific report types, agent projections, and CLI options.
2
1
  export {};
@@ -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.8.1",
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.45.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,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
- }
@@ -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
- }
@@ -1,7 +0,0 @@
1
- mutation ResolveThread($threadId: ID!) {
2
- resolveReviewThread(input: { threadId: $threadId }) {
3
- thread {
4
- isResolved
5
- }
6
- }
7
- }