pr-shepherd 0.10.2 → 0.10.3

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
4
- "version": "0.10.2",
4
+ "version": "0.10.3",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -128,6 +128,7 @@ Recommendations:
128
128
  - Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. As it uses `/loop`, it will continue working when your rate limit window is reset. The loop cancels automatically when the PR is merged, closed, or after the ready-delay elapses.
129
129
  - Instruct your agents to write comments in a single review (comment, changes requested, or approved). This allows the review's comments/threads to be minimized or resolved together, keeping your pull request history clean. If you write inline comments outside of a review, each comment would still show up in the pull request history and take up space.
130
130
  - Avoid sticky comments as they will continue to be hidden. Instead, just make a new comment, especially on reviews. If you really want sticky comments, instruct your agent to unhide/unminimize them when updating them.
131
+ - Avoid having automation edit comments, reviews, or threads in place because updated items get minimized. Instead, always make a new review, comment, thread, etc.
131
132
 
132
133
  ## Design Principles
133
134
 
@@ -63,6 +63,13 @@ export function formatFixCodeResult(header, result) {
63
63
  sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
64
64
  }
65
65
  }
66
+ if (result.fix.editedSummaries.length > 0) {
67
+ sections.push("## Review summaries (edited since first look — already minimized; do not re-minimize)");
68
+ for (const r of result.fix.editedSummaries) {
69
+ sections.push(`### \`reviewId=${r.id}\` (@${r.author})`);
70
+ sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
71
+ }
72
+ }
66
73
  const firstLookSummaryIds = new Set(result.fix.firstLookSummaries.map((r) => r.id));
67
74
  const seenSummaryIds = result.fix.reviewSummaryIds.filter((id) => !firstLookSummaryIds.has(id));
68
75
  if (seenSummaryIds.length > 0) {
@@ -84,7 +91,8 @@ export function formatFixCodeResult(header, result) {
84
91
  bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
85
92
  }
86
93
  for (const c of result.fix.firstLookComments) {
87
- bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
94
+ const editedSuffix = c.edited ? ", edited" : "";
95
+ bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
88
96
  }
89
97
  sections.push(bullets.join("\n"));
90
98
  }
@@ -44,7 +44,8 @@ export function formatFetchResult(result) {
44
44
  bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
45
45
  }
46
46
  for (const c of result.firstLookComments) {
47
- bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
47
+ const editedSuffix = c.edited ? ", edited" : "";
48
+ bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
48
49
  }
49
50
  sections.push(bullets.join("\n"));
50
51
  }
@@ -55,6 +55,9 @@ export function projectIterateLean(result) {
55
55
  ...(result.fix.firstLookSummaries.length > 0 && {
56
56
  firstLookSummaries: result.fix.firstLookSummaries,
57
57
  }),
58
+ ...(result.fix.editedSummaries.length > 0 && {
59
+ editedSummaries: result.fix.editedSummaries,
60
+ }),
58
61
  ...(result.fix.surfacedApprovals.length > 0 && {
59
62
  surfacedApprovals: result.fix.surfacedApprovals,
60
63
  }),
@@ -6,7 +6,10 @@ export function renderBodyPreview(body) {
6
6
  return firstLine.slice(0, BODY_PREVIEW_MAX);
7
7
  }
8
8
  export function renderFirstLookStatusTag(t) {
9
- return t.autoResolved ? "[status: outdated, auto-resolved]" : `[status: ${t.firstLookStatus}]`;
9
+ const editedSuffix = t.edited ? ", edited" : "";
10
+ return t.autoResolved
11
+ ? `[status: outdated, auto-resolved${editedSuffix}]`
12
+ : `[status: ${t.firstLookStatus}${editedSuffix}]`;
10
13
  }
11
14
  export function renderThreadBullet(t, opts = {}) {
12
15
  const link = t.url ? ` [↗](${t.url})` : "";
@@ -31,6 +31,7 @@ export function makeIterateResult(action = "wait") {
31
31
  actionableComments: [],
32
32
  reviewSummaryIds: [],
33
33
  firstLookSummaries: [],
34
+ editedSummaries: [],
34
35
  surfacedApprovals: [],
35
36
  checks: [],
36
37
  changesRequestedReviews: [],
@@ -1,17 +1,3 @@
1
- /**
2
- * `shepherd check [PR]`
3
- *
4
- * Read-only snapshot of PR status. Fetches CI + comments + merge status in
5
- * one GraphQL request, applies all classifiers, and returns a ShepherdReport.
6
- *
7
- * Exit codes:
8
- * 0 READY — all checks passed, no unresolved threads, CLEAN merge status.
9
- * 1 FAILING — CI has red checks, or merge has conflicts.
10
- * 1 PENDING — CI passing but merge blocked (BLOCKED, UNSTABLE, or BEHIND).
11
- * 1 UNKNOWN — merge state unresolvable.
12
- * 2 IN_PROGRESS — CI checks still running.
13
- * 3 UNRESOLVED_COMMENTS — CI ok but actionable threads remain.
14
- */
15
1
  import { fetchPrBatch } from "../github/batch.mjs";
16
2
  import { getRepoInfo, getCurrentPrNumber, getMergeableState } from "../github/client.mjs";
17
3
  import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
@@ -21,7 +7,7 @@ import { autoResolveOutdated } from "../comments/resolve.mjs";
21
7
  import { deriveMergeStatus } from "../merge-status/derive.mjs";
22
8
  import { loadConfig } from "../config/load.mjs";
23
9
  import { computeStatus } from "./check-status.mjs";
24
- import { loadSeenSet, markSeen } from "../state/seen-comments.mjs";
10
+ import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
25
11
  export async function runCheck(opts) {
26
12
  const repo = await getRepoInfo();
27
13
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
@@ -29,14 +15,10 @@ export async function runCheck(opts) {
29
15
  throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
30
16
  }
31
17
  const config = loadConfig();
32
- // Only paginate APPROVED reviews when the caller will actually minimize them.
33
- // Otherwise the first-page cap of 50 (already in the batch) is plenty — no extra round-trip.
34
18
  const paginateApprovedReviews = config.iterate.minimizeApprovals;
35
19
  const result = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews });
36
20
  let batchData = result.data;
37
- // GraphQL sometimes returns UNKNOWN for mergeable/mergeStateStatus while the
38
- // REST API already has the correct value. Fall back to REST in that case.
39
- // Skip for non-OPEN PRs — REST also returns UNKNOWN for merged/closed PRs.
21
+ // Fall back to REST when GraphQL returns UNKNOWN — skip for non-OPEN PRs.
40
22
  if ((batchData.state ?? "OPEN") === "OPEN" &&
41
23
  (batchData.mergeable === "UNKNOWN" || batchData.mergeStateStatus === "UNKNOWN")) {
42
24
  const restState = await getMergeableState(prNumber, repo.owner, repo.name);
@@ -59,7 +41,6 @@ export async function runCheck(opts) {
59
41
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
60
42
  const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
61
43
  const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
62
- // Auto-resolve outdated threads.
63
44
  const outdated = getOutdatedThreads(unresolvedThreads);
64
45
  let autoResolved = [];
65
46
  let autoResolveErrors = [];
@@ -69,51 +50,70 @@ export async function runCheck(opts) {
69
50
  autoResolveErrors = errors;
70
51
  }
71
52
  const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
72
- // First-look: collect previously-hidden items not yet seen by the agent.
73
53
  const outdatedCandidates = batchData.reviewThreads.filter((t) => t.isOutdated);
74
54
  const resolvedCandidates = batchData.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
75
55
  const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
76
56
  const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
77
- const seenSet = await loadSeenSet(stateKey);
57
+ const seenMap = await loadSeenMap(stateKey);
78
58
  const autoResolvedIds = new Set(autoResolved.map((t) => t.id));
79
59
  const firstLookThreads = [
80
- ...outdatedCandidates
81
- .filter((t) => !seenSet.has(t.id))
82
- .map((t) => ({
83
- ...t,
84
- firstLookStatus: "outdated",
85
- autoResolved: autoResolvedIds.has(t.id),
86
- })),
87
- ...resolvedCandidates
88
- .filter((t) => !seenSet.has(t.id))
89
- .map((t) => ({ ...t, firstLookStatus: "resolved" })),
90
- ...minimizedThreadCandidates
91
- .filter((t) => !seenSet.has(t.id))
92
- .map((t) => ({ ...t, firstLookStatus: "minimized" })),
60
+ ...outdatedCandidates.flatMap((t) => {
61
+ const cls = classifyItem(t.id, t.body, seenMap);
62
+ if (cls === "unchanged")
63
+ return [];
64
+ const base = {
65
+ ...t,
66
+ firstLookStatus: "outdated",
67
+ autoResolved: autoResolvedIds.has(t.id),
68
+ };
69
+ return cls === "edited" ? [{ ...base, edited: true }] : [base];
70
+ }),
71
+ ...resolvedCandidates.flatMap((t) => {
72
+ const cls = classifyItem(t.id, t.body, seenMap);
73
+ if (cls === "unchanged")
74
+ return [];
75
+ const base = { ...t, firstLookStatus: "resolved" };
76
+ return cls === "edited" ? [{ ...base, edited: true }] : [base];
77
+ }),
78
+ ...minimizedThreadCandidates.flatMap((t) => {
79
+ const cls = classifyItem(t.id, t.body, seenMap);
80
+ if (cls === "unchanged")
81
+ return [];
82
+ const base = { ...t, firstLookStatus: "minimized" };
83
+ return cls === "edited" ? [{ ...base, edited: true }] : [base];
84
+ }),
93
85
  ];
94
- const firstLookComments = minimizedCommentCandidates
95
- .filter((c) => !seenSet.has(c.id))
96
- .map((c) => ({ ...c, firstLookStatus: "minimized" }));
97
- // Split review summaries into first-look (unseen — surface body) vs seen (minimize silently).
98
- const firstLookSummaries = batchData.reviewSummaries.filter((r) => !seenSet.has(r.id));
99
- const seenSummaries = batchData.reviewSummaries.filter((r) => seenSet.has(r.id));
100
- // Mark first-look items as seen (best-effort — markSeen never throws).
86
+ const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
87
+ const cls = classifyItem(c.id, c.body, seenMap);
88
+ if (cls === "unchanged")
89
+ return [];
90
+ const base = { ...c, firstLookStatus: "minimized" };
91
+ return cls === "edited" ? [{ ...base, edited: true }] : [base];
92
+ });
93
+ const firstLookSummaries = [];
94
+ const editedSummaries = [];
95
+ const seenSummaries = [];
96
+ for (const r of batchData.reviewSummaries) {
97
+ const cls = classifyItem(r.id, r.body, seenMap);
98
+ if (cls === "new")
99
+ firstLookSummaries.push(r);
100
+ else if (cls === "edited")
101
+ editedSummaries.push(r);
102
+ else
103
+ seenSummaries.push(r);
104
+ }
101
105
  await Promise.allSettled([
102
- ...firstLookThreads.map((t) => markSeen(stateKey, t.id)),
103
- ...firstLookComments.map((c) => markSeen(stateKey, c.id)),
104
- ...firstLookSummaries.map((r) => markSeen(stateKey, r.id)),
106
+ ...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
107
+ ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
108
+ ...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
105
109
  ]);
106
- // Actionable: all active threads and all visible comments (no classification — LLM handles triage).
107
110
  const actionableThreads = activeThreads;
108
111
  const actionableComments = visibleComments;
109
- // Derive merge status.
110
112
  const mergeStatus = deriveMergeStatus(batchData);
111
- // Derive blockedByFilteredCheck ghost state.
112
113
  const blockedByFilteredCheck = mergeStatus.status === "BLOCKED" &&
113
114
  !verdict.anyFailing &&
114
115
  !verdict.anyInProgress &&
115
116
  verdict.filteredNames.length > 0;
116
- // Compute overall status.
117
117
  const status = computeStatus(verdict, actionableThreads.length, actionableComments.length, mergeStatus, batchData.changesRequestedReviews.length);
118
118
  return {
119
119
  pr: prNumber,
@@ -144,6 +144,7 @@ export async function runCheck(opts) {
144
144
  changesRequestedReviews: batchData.changesRequestedReviews,
145
145
  reviewSummaries: seenSummaries,
146
146
  firstLookSummaries,
147
+ editedSummaries,
147
148
  approvedReviews: batchData.approvedReviews,
148
149
  };
149
150
  }
@@ -1,13 +1,24 @@
1
1
  export function classifyReviewSummaries(summaries, approvals, minimizeApprovals) {
2
- // Both first-look and seen summaries go into the minimize mutation; first-look bodies are
3
- // rendered in the output so the agent sees them before the minimize happens.
2
+ // First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
3
+ // they are already minimized server-side (body changed after minimize was applied).
4
+ // First-look bodies are rendered so the agent sees them before the minimize happens.
4
5
  const minimizeIds = [...summaries.firstLook, ...summaries.seen].map((r) => r.id);
5
6
  if (minimizeApprovals) {
6
7
  for (const r of approvals)
7
8
  minimizeIds.push(r.id);
8
- return { minimizeIds, firstLookSummaries: summaries.firstLook, surfacedApprovals: [] };
9
+ return {
10
+ minimizeIds,
11
+ firstLookSummaries: summaries.firstLook,
12
+ editedSummaries: summaries.edited,
13
+ surfacedApprovals: [],
14
+ };
9
15
  }
10
- return { minimizeIds, firstLookSummaries: summaries.firstLook, surfacedApprovals: approvals };
16
+ return {
17
+ minimizeIds,
18
+ firstLookSummaries: summaries.firstLook,
19
+ editedSummaries: summaries.edited,
20
+ surfacedApprovals: approvals,
21
+ };
11
22
  }
12
23
  export function buildResolveCommand(threads, allCommentIds, reviews, checks, prNumber) {
13
24
  const argv = ["npx", "pr-shepherd", "resolve", String(prNumber)];
@@ -6,7 +6,7 @@ import { buildFixInstructions } from "./render.mjs";
6
6
  import { applyStallGuard } from "./stall.mjs";
7
7
  import { tryCancelRun } from "./helpers.mjs";
8
8
  export async function handleFixCode(ctx) {
9
- const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, surfacedApprovals, } = ctx;
9
+ const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = ctx;
10
10
  const failingChecks = report.checks.failing;
11
11
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
12
12
  const isNewSha = stored?.headSha !== headSha;
@@ -73,7 +73,7 @@ export async function handleFixCode(ctx) {
73
73
  }
74
74
  const firstLookThreads = report.threads.firstLook;
75
75
  const firstLookComments = report.comments.firstLook;
76
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries);
76
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries);
77
77
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
78
78
  ...base,
79
79
  baseBranch: baseLookup.branch,
@@ -84,6 +84,7 @@ export async function handleFixCode(ctx) {
84
84
  actionableComments,
85
85
  reviewSummaryIds,
86
86
  firstLookSummaries,
87
+ editedSummaries,
87
88
  surfacedApprovals,
88
89
  checks,
89
90
  changesRequestedReviews,
@@ -91,7 +91,11 @@ export async function runIterate(opts) {
91
91
  }
92
92
  const headSha = (await getCurrentHeadSha()) ?? "unknown";
93
93
  const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
94
- const { minimizeIds: reviewSummaryIds, firstLookSummaries, surfacedApprovals, } = classifyReviewSummaries({ firstLook: report.firstLookSummaries, seen: report.reviewSummaries }, report.approvedReviews, config.iterate.minimizeApprovals);
94
+ const { minimizeIds: reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = classifyReviewSummaries({
95
+ firstLook: report.firstLookSummaries,
96
+ seen: report.reviewSummaries,
97
+ edited: report.editedSummaries,
98
+ }, report.approvedReviews, config.iterate.minimizeApprovals);
95
99
  const hasActionableWork = report.threads.actionable.length > 0 ||
96
100
  report.comments.actionable.length > 0 ||
97
101
  report.changesRequestedReviews.length > 0 ||
@@ -111,6 +115,7 @@ export async function runIterate(opts) {
111
115
  repoName,
112
116
  reviewSummaryIds,
113
117
  firstLookSummaries,
118
+ editedSummaries,
114
119
  surfacedApprovals,
115
120
  });
116
121
  }
@@ -1,18 +1,7 @@
1
1
  /**
2
- * Render a resolve command as a shell snippet for the narrow placeholder-based
3
- * invocation used by iterate.
4
- *
5
- * This is not a general-purpose shell escaper. It only wraps
6
- * `$DISMISS_MESSAGE` and whitespace-bearing `rc.argv` entries in double quotes
7
- * so the surrounding command template can later substitute placeholder values.
8
- *
9
- * Callers must preserve that contract:
10
- * - placeholder substitution must replace the entire quoted token (for example,
11
- * replace `"$DISMISS_MESSAGE"` as a whole, not text inside the quotes);
12
- * - `rc.argv` must not contain `"`, `$`, `` ` ``, or `\`, because this helper
13
- * does not escape them and will throw if they are present;
14
- * - `$HEAD_SHA` must not appear in `rc.argv`; when `requiresHeadSha` is set it
15
- * is appended separately below as the already-quoted token `"$HEAD_SHA"`.
2
+ * Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE` and whitespace-bearing
3
+ * argv entries in double quotes for placeholder substitution. Throws if argv contains `"`, `$`,
4
+ * `` ` ``, or `\`. `$HEAD_SHA` is appended separately when `requiresHeadSha` is set.
16
5
  */
17
6
  export function renderResolveCommand(rc) {
18
7
  const needsQuoting = (arg) => {
@@ -29,7 +18,7 @@ export function renderResolveCommand(rc) {
29
18
  }
30
19
  return parts.join(" ");
31
20
  }
32
- export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = []) {
21
+ export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = []) {
33
22
  const instructions = [];
34
23
  const hasSuggestions = threads.some((t) => t.suggestion);
35
24
  if (hasSuggestions) {
@@ -80,6 +69,12 @@ export function buildFixInstructions(threads, actionableComments, checks, review
80
69
  if (firstLookSummaries.length > 0) {
81
70
  instructions.push(`Review the bodies shown under \`## Review summaries (first look — to be minimized)\` — you are seeing these for the first time. Their IDs are already included in the \`resolve:\` command's \`--minimize-comment-ids\`; if any warrants a \`## Shepherd Journal\` entry, record it before running resolve.`);
82
71
  }
72
+ const editedTotal = editedSummaries.length +
73
+ firstLookThreads.filter((t) => t.edited).length +
74
+ firstLookComments.filter((c) => c.edited).length;
75
+ if (editedTotal > 0) {
76
+ instructions.push(`Items under \`## Review summaries (edited since first look)\` and any first-look bullet tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body. Do **not** include their IDs in \`--minimize-comment-ids\`, \`--resolve-thread-ids\`, or \`--dismiss-review-ids\` — they are already closed or minimized on GitHub.`);
77
+ }
83
78
  if (resolveCommand.hasMutations) {
84
79
  const substituteParts = [];
85
80
  if (resolveCommand.requiresHeadSha) {
@@ -23,6 +23,11 @@ export function buildFetchInstructions(prNumber, result) {
23
23
  if (firstLookTotal > 0) {
24
24
  instructions.push(`Items in \`## First-look items\` are for acknowledgement only — do not pass their IDs to \`--resolve-thread-ids\`, \`--minimize-comment-ids\`, or \`--dismiss-review-ids\`. Acknowledge each one with a one-line classification (e.g. "outdated — addressed by commit abc1234", "resolved — already fixed", "minimized — noise").`);
25
25
  }
26
+ const editedTotal = firstLookThreads.filter((t) => t.edited).length +
27
+ firstLookComments.filter((c) => c.edited).length;
28
+ if (editedTotal > 0) {
29
+ instructions.push(`First-look bullets tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body. Do **not** include their IDs in \`--resolve-thread-ids\`, \`--minimize-comment-ids\`, or \`--dismiss-review-ids\` — they are already closed or minimized on GitHub.`);
30
+ }
26
31
  if (hasSuggestions) {
27
32
  instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\`, one thread at a time. On \`applied: true\` mark it Fixed — the CLI already resolved the thread, so exclude the ID from \`--resolve-thread-ids\`. On \`applied: false\` read \`reason\` and \`patch\`, then fall through to the manual fix step — do not retry the same command. Optionally pass \`--dry-run\` (omitting \`--message\`) if you want to inspect the unified diff before it mutates the working tree — the CLI validates with \`git apply --check\`, returns the patch and \`valid: true/false\`, and exits \`1\` on drift without committing or resolving the thread.`);
28
33
  }
@@ -5,32 +5,59 @@ import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mj
5
5
  import { loadConfig } from "../config/load.mjs";
6
6
  import { extractSuggestion } from "../suggestions/extract.mjs";
7
7
  import { buildFetchInstructions } from "./resolve-instructions.mjs";
8
- import { loadSeenSet, markSeen } from "../state/seen-comments.mjs";
9
- /**
10
- * Fetch mode: auto-resolve outdated threads and return all active items for LLM triage.
11
- */
8
+ import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
9
+ /** Fetch mode: auto-resolve outdated threads and return all active items for LLM triage. */
12
10
  export async function runResolveFetch(opts) {
13
11
  const repo = await getRepoInfo();
14
12
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
15
13
  if (prNumber === null) {
16
14
  throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
17
15
  }
18
- // Always bypass cache for resolve — we need fresh data before mutating.
19
16
  const { data } = await fetchPrBatch(prNumber, repo);
20
17
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
21
18
  const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
22
19
  const visibleComments = data.comments.filter((c) => !c.isMinimized);
23
- // First-look: collect items that would normally be hidden and check seen markers.
24
20
  const outdatedCandidates = data.reviewThreads.filter((t) => t.isOutdated);
25
21
  const resolvedCandidates = data.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
26
22
  const minimizedThreadCandidates = data.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
27
23
  const minimizedCommentCandidates = data.comments.filter((c) => c.isMinimized);
28
- const seenSet = await loadSeenSet(stateKey);
29
- const unseenOutdated = outdatedCandidates.filter((t) => !seenSet.has(t.id));
30
- const unseenResolved = resolvedCandidates.filter((t) => !seenSet.has(t.id));
31
- const unseenMinimizedThreads = minimizedThreadCandidates.filter((t) => !seenSet.has(t.id));
32
- const unseenMinimizedComments = minimizedCommentCandidates.filter((c) => !seenSet.has(c.id));
33
- // Auto-resolve outdated (same as before — fires regardless of first-look status).
24
+ const seenMap = await loadSeenMap(stateKey);
25
+ const unseenOutdated = [];
26
+ const editedOutdated = [];
27
+ for (const t of outdatedCandidates) {
28
+ const cls = classifyItem(t.id, t.body, seenMap);
29
+ if (cls === "new")
30
+ unseenOutdated.push(t);
31
+ else if (cls === "edited")
32
+ editedOutdated.push(t);
33
+ }
34
+ const unseenResolved = [];
35
+ const editedResolved = [];
36
+ for (const t of resolvedCandidates) {
37
+ const cls = classifyItem(t.id, t.body, seenMap);
38
+ if (cls === "new")
39
+ unseenResolved.push(t);
40
+ else if (cls === "edited")
41
+ editedResolved.push(t);
42
+ }
43
+ const unseenMinimizedThreads = [];
44
+ const editedMinimizedThreads = [];
45
+ for (const t of minimizedThreadCandidates) {
46
+ const cls = classifyItem(t.id, t.body, seenMap);
47
+ if (cls === "new")
48
+ unseenMinimizedThreads.push(t);
49
+ else if (cls === "edited")
50
+ editedMinimizedThreads.push(t);
51
+ }
52
+ const unseenMinimizedComments = [];
53
+ const editedMinimizedComments = [];
54
+ for (const c of minimizedCommentCandidates) {
55
+ const cls = classifyItem(c.id, c.body, seenMap);
56
+ if (cls === "new")
57
+ unseenMinimizedComments.push(c);
58
+ else if (cls === "edited")
59
+ editedMinimizedComments.push(c);
60
+ }
34
61
  const outdated = getOutdatedThreads(unresolvedThreads);
35
62
  const autoResolvedIds = new Set();
36
63
  if (outdated.length > 0) {
@@ -56,17 +83,37 @@ export async function runResolveFetch(opts) {
56
83
  firstLookStatus: "outdated",
57
84
  autoResolved: autoResolvedIds.has(t.id),
58
85
  })),
86
+ ...editedOutdated.map((t) => ({
87
+ ...t,
88
+ firstLookStatus: "outdated",
89
+ autoResolved: autoResolvedIds.has(t.id),
90
+ edited: true,
91
+ })),
59
92
  ...unseenResolved.map((t) => ({ ...t, firstLookStatus: "resolved" })),
93
+ ...editedResolved.map((t) => ({
94
+ ...t,
95
+ firstLookStatus: "resolved",
96
+ edited: true,
97
+ })),
60
98
  ...unseenMinimizedThreads.map((t) => ({ ...t, firstLookStatus: "minimized" })),
99
+ ...editedMinimizedThreads.map((t) => ({
100
+ ...t,
101
+ firstLookStatus: "minimized",
102
+ edited: true,
103
+ })),
104
+ ];
105
+ const firstLookComments = [
106
+ ...unseenMinimizedComments.map((c) => ({ ...c, firstLookStatus: "minimized" })),
107
+ ...editedMinimizedComments.map((c) => ({
108
+ ...c,
109
+ firstLookStatus: "minimized",
110
+ edited: true,
111
+ })),
61
112
  ];
62
- const firstLookComments = unseenMinimizedComments.map((c) => ({
63
- ...c,
64
- firstLookStatus: "minimized",
65
- }));
66
- // Mark first-look items as seen (best-effort — markSeen never throws).
113
+ // Mark new and edited items as seen (best-effort markSeen never throws).
67
114
  await Promise.allSettled([
68
- ...firstLookThreads.map((t) => markSeen(stateKey, t.id)),
69
- ...firstLookComments.map((c) => markSeen(stateKey, c.id)),
115
+ ...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
116
+ ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
70
117
  ]);
71
118
  const result = {
72
119
  prNumber,
@@ -80,9 +127,7 @@ export async function runResolveFetch(opts) {
80
127
  };
81
128
  return { ...result, instructions: buildFetchInstructions(prNumber, result) };
82
129
  }
83
- /**
84
- * Mutation mode: resolve/minimize/dismiss by ID.
85
- */
130
+ /** Mutation mode: resolve/minimize/dismiss by ID. */
86
131
  export async function runResolveMutate(opts) {
87
132
  const repo = await getRepoInfo();
88
133
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
@@ -112,7 +112,8 @@ export function formatText(report) {
112
112
  lines.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
113
113
  }
114
114
  for (const c of firstLookComments) {
115
- lines.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
115
+ const editedSuffix = c.edited ? ", edited" : "";
116
+ lines.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
116
117
  }
117
118
  firstLookSection = lines.join("\n");
118
119
  }
@@ -1,10 +1,32 @@
1
- import { readFile, writeFile, mkdir, access, readdir } from "node:fs/promises";
1
+ import { readFile, writeFile, rename, unlink, mkdir, access, readdir } from "node:fs/promises";
2
2
  import { join, dirname } from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
3
4
  import { SAFE_SEGMENT } from "../util/path-segment.mjs";
4
5
  import { resolveStateBase } from "./base.mjs";
5
6
  // ---------------------------------------------------------------------------
6
7
  // Public API
7
8
  // ---------------------------------------------------------------------------
9
+ /** Compute a 16-hex-char SHA-256 prefix of a comment body. */
10
+ export function hashBody(body) {
11
+ return createHash("sha256").update(body, "utf8").digest("hex").slice(0, 16);
12
+ }
13
+ /**
14
+ * Classify a candidate item against the seen map.
15
+ *
16
+ * - "new" — no marker exists; surface the body and write the marker.
17
+ * - "edited" — marker exists but stored hash differs from the current body;
18
+ * surface the updated body and update the marker hash.
19
+ * - "unchanged" — marker exists and hash matches (or marker has no hash, which
20
+ * is treated conservatively as unchanged).
21
+ */
22
+ export function classifyItem(id, body, map) {
23
+ const m = map.get(id);
24
+ if (!m)
25
+ return "new";
26
+ if (typeof m.bodyHash === "string" && m.bodyHash !== hashBody(body))
27
+ return "edited";
28
+ return "unchanged";
29
+ }
8
30
  /**
9
31
  * Read the seen/ directory once and return a Set of already-seen IDs.
10
32
  * Prefer this over repeated hasSeen() calls to avoid EMFILE on large PRs.
@@ -20,6 +42,32 @@ export async function loadSeenSet(key) {
20
42
  return new Set();
21
43
  }
22
44
  }
45
+ /**
46
+ * Read the seen/ directory and return a Map from ID to SeenMarker.
47
+ * Used when the caller needs the stored bodyHash to detect in-place edits.
48
+ * Returns an empty Map if the directory does not yet exist.
49
+ */
50
+ export async function loadSeenMap(key) {
51
+ const map = new Map();
52
+ try {
53
+ const dir = resolveDir(key);
54
+ const entries = await readdir(dir);
55
+ const ids = entries.filter((e) => e.endsWith(".json")).map((e) => e.slice(0, -5));
56
+ for (const id of ids) {
57
+ try {
58
+ const raw = await readFile(join(dir, `${id}.json`), "utf8");
59
+ map.set(id, JSON.parse(raw));
60
+ }
61
+ catch {
62
+ // unreadable or malformed — skip
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ // directory doesn't exist or unreadable — return empty map
68
+ }
69
+ return map;
70
+ }
23
71
  /** Return true if a "seen" marker exists for this id. */
24
72
  export async function hasSeen(key, id) {
25
73
  try {
@@ -30,17 +78,50 @@ export async function hasSeen(key, id) {
30
78
  return false;
31
79
  }
32
80
  }
33
- /** Write a "seen" marker for this id. Idempotent — preserves original seenAt on double-write. */
34
- export async function markSeen(key, id) {
81
+ /**
82
+ * Write (or update) a "seen" marker for this id, storing the body hash so
83
+ * in-place edits can be detected on future fetches.
84
+ *
85
+ * - First call (no existing marker): creates `{ seenAt: now, bodyHash }`.
86
+ * - Subsequent call, hash unchanged: no-op (skips the write).
87
+ * - Subsequent call, hash changed: updates `bodyHash`, preserves original `seenAt`.
88
+ *
89
+ * All errors are silently swallowed — the marker is best-effort.
90
+ */
91
+ export async function markSeen(key, id, body) {
92
+ let tmp;
35
93
  try {
36
94
  const path = resolvePath(key, id);
37
95
  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" });
96
+ const newHash = hashBody(body);
97
+ let existing = null;
98
+ try {
99
+ const raw = await readFile(path, "utf8");
100
+ existing = JSON.parse(raw);
101
+ }
102
+ catch {
103
+ // no existing marker — will create below
104
+ }
105
+ if (existing !== null && existing.bodyHash === newHash)
106
+ return;
107
+ const seenAt = existing?.seenAt ?? Date.now();
108
+ tmp = `${path}.${randomUUID()}.tmp`;
109
+ await writeFile(tmp, JSON.stringify({ seenAt, bodyHash: newHash }), "utf8");
110
+ await rename(tmp, path);
111
+ tmp = undefined;
41
112
  }
42
113
  catch {
43
- // EEXIST = already seen. All other errors are best-effort.
114
+ // best-effort
115
+ }
116
+ finally {
117
+ if (tmp !== undefined) {
118
+ try {
119
+ await unlink(tmp);
120
+ }
121
+ catch {
122
+ // best-effort cleanup
123
+ }
124
+ }
44
125
  }
45
126
  }
46
127
  /** Read the full marker for inspection (returns null on miss or error). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",