pr-shepherd 0.26.2 → 0.27.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.26.2",
4
+ "version": "0.27.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -88,7 +88,7 @@ Direct CLI:
88
88
 
89
89
  ```sh
90
90
  pr-shepherd 42 # poll until non-WAIT or timeout
91
- pr-shepherd 42 --interval 45s --timeout 4m
91
+ pr-shepherd 42 --interval 60s --timeout 270s
92
92
  pr-shepherd 42 --quiet-status # print only changed WAIT status snapshots
93
93
  pr-shepherd 42 --ready-delay 15m
94
94
  pr-shepherd iterate 42 # single tick
@@ -1,3 +1,4 @@
1
+ import { parseSecondsDurationParts } from "./exit-codes.mjs";
1
2
  export function validateSecondsDurationFlag(command, flag, value, presentAsSeparateArg) {
2
3
  if (value === null) {
3
4
  if (presentAsSeparateArg) {
@@ -13,8 +14,8 @@ export function validateSecondsDurationFlag(command, flag, value, presentAsSepar
13
14
  process.exitCode = 1;
14
15
  return null;
15
16
  }
16
- if (!/^[1-9]\d*(?:s|sec|seconds?|m|min|minutes?|h|hours?)?$/.test(trimmed)) {
17
- process.stderr.write(`${command}: invalid ${flag}: ${value}. Expected a duration like 30s, 5m, 1h, or bare seconds (e.g. 30).\n`);
17
+ if (!parseSecondsDurationParts(trimmed)) {
18
+ process.stderr.write(`${command}: invalid ${flag}: ${value}. Expected a duration like 30s, 4.5m, 1h, or bare seconds (e.g. 30).\n`);
18
19
  process.exitCode = 1;
19
20
  return null;
20
21
  }
@@ -1,4 +1,17 @@
1
1
  import { loadConfig } from "../config/load.mjs";
2
+ const SECOND_DURATION_UNITS = new Set([
3
+ "s",
4
+ "sec",
5
+ "second",
6
+ "seconds",
7
+ "m",
8
+ "min",
9
+ "minute",
10
+ "minutes",
11
+ "h",
12
+ "hour",
13
+ "hours",
14
+ ]);
2
15
  export function parseDurationToMinutes(s, defaultMinutes) {
3
16
  const m = /^(\d+)(m|min|minutes?|h|hours?)?$/.exec(s.trim());
4
17
  if (!m)
@@ -9,19 +22,32 @@ export function parseDurationToMinutes(s, defaultMinutes) {
9
22
  return n * 60;
10
23
  return n;
11
24
  }
25
+ export function parseSecondsDurationParts(s) {
26
+ const trimmed = s.trim();
27
+ const match = /^(\d+(?:\.\d+)?)([a-z]+)?$/.exec(trimmed);
28
+ if (!match)
29
+ return null;
30
+ const amount = match[1];
31
+ const explicitUnit = match[2];
32
+ if (!amount || (amount.includes(".") && !explicitUnit))
33
+ return null;
34
+ const unit = explicitUnit ?? "s";
35
+ if (!SECOND_DURATION_UNITS.has(unit))
36
+ return null;
37
+ const value = Number(amount);
38
+ if (!Number.isFinite(value) || value <= 0)
39
+ return null;
40
+ return { value, unit };
41
+ }
12
42
  export function parseDurationToSeconds(s, defaultSeconds) {
13
- const m = /^(\d+)(s|sec|seconds?|m|min|minutes?|h|hours?)?$/.exec(s.trim());
14
- if (!m)
15
- return defaultSeconds;
16
- const n = parseInt(m[1], 10);
17
- if (!Number.isFinite(n))
43
+ const parsed = parseSecondsDurationParts(s);
44
+ if (!parsed)
18
45
  return defaultSeconds;
19
- const unit = m[2] ?? "s";
20
- if (unit.startsWith("h"))
21
- return n * 3600;
22
- if (unit.startsWith("m"))
23
- return n * 60;
24
- return n;
46
+ if (parsed.unit.startsWith("h"))
47
+ return parsed.value * 3600;
48
+ if (parsed.unit.startsWith("m"))
49
+ return parsed.value * 60;
50
+ return parsed.value;
25
51
  }
26
52
  export function statusToExitCode(status) {
27
53
  switch (status) {
@@ -109,8 +109,8 @@ Usage:
109
109
  pr-shepherd poll [PR] [poll-flags] [iterate-flags]
110
110
 
111
111
  Poll flags:
112
- --interval <duration> Sleep between WAIT ticks. Default: 30s.
113
- --timeout <duration> Maximum wall-clock wait. Default: 5m.
112
+ --interval <duration> Sleep between WAIT ticks. Default: 60s.
113
+ --timeout <duration> Maximum wall-clock wait. Default: 4.5m.
114
114
  --quiet-status During WAIT polling, print only changed status snapshots.
115
115
 
116
116
  Forwarded iterate flags:
@@ -122,7 +122,7 @@ Forwarded iterate flags:
122
122
  --verbose Include verbose iterate fields and detailed per-tick lines.
123
123
  --help, -h Print this help and exit before GitHub, git, config, or log I/O.
124
124
 
125
- Durations accept seconds, minutes, or hours: 30s, 2m, 1h, or bare seconds.
125
+ Durations accept seconds, minutes, or hours: 30s, 4.5m, 1h, or bare seconds.
126
126
  Each WAIT tick writes a single dot to stderr by default; --quiet-status prints only changed WAIT snapshots, and --verbose emits detailed per-tick lines.
127
127
 
128
128
  Exit codes:
@@ -42,8 +42,8 @@ Iterate flags:
42
42
  --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.
43
43
 
44
44
  Poll flags:
45
- --interval <duration> Sleep between WAIT ticks. Default: 30s.
46
- --timeout <duration> Maximum poll wall-clock wait. Default: 5m.
45
+ --interval <duration> Delay between WAIT ticks. Default: 60s.
46
+ --timeout <duration> Poll wall-clock cap. Default: 4.5m.
47
47
  --quiet-status During WAIT polling, print only changed status snapshots.
48
48
 
49
49
  Clean variants:
@@ -59,6 +59,6 @@ Exit codes for iterate and poll:
59
59
  2 CANCEL
60
60
  3 ESCALATE
61
61
 
62
- Durations accept seconds, minutes, or hours: 30s, 2m, 1h, or bare seconds.
62
+ Duration examples: 30s, 4.5m, 1h, or bare seconds.
63
63
 
64
64
  Run 'pr-shepherd <command> --help' for command-specific details.`;
@@ -94,6 +94,9 @@ export function renderEditedCommentTag(c) {
94
94
  return c.edited ? "[edited since first look]" : undefined;
95
95
  }
96
96
  export function renderReviewBullet(r, opts = {}) {
97
+ if (r.staleBotCr) {
98
+ return `- \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType)}) [pending dismissal — already surfaced; include in \`--dismiss-review-ids\`]`;
99
+ }
97
100
  const bodySuffix = opts.includeBody && r.body != null && r.body !== "" ? `: ${renderBodyPreview(r.body)}` : "";
98
101
  return `- \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType)})${bodySuffix}`;
99
102
  }
@@ -5,8 +5,8 @@ import { parseDurationToSeconds } from "./exit-codes.mjs";
5
5
  import { validateSecondsDurationFlag } from "./duration-flag.mjs";
6
6
  import { parseIterateFlags } from "./iterate-flags.mjs";
7
7
  import { emitIterateResult } from "./iterate-emitter.mjs";
8
- const DEFAULT_POLL_INTERVAL_SECONDS = 30;
9
- const DEFAULT_POLL_TIMEOUT_SECONDS = 300;
8
+ const DEFAULT_POLL_INTERVAL_SECONDS = 60;
9
+ const DEFAULT_POLL_TIMEOUT_SECONDS = 270;
10
10
  export async function handlePoll(args) {
11
11
  const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
12
12
  const cfg = loadConfig();
@@ -13,7 +13,7 @@ import { isBlockedByFilteredCheck, refreshReadyMergeability, refreshUnknownMerge
13
13
  import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
14
14
  import { threadTranscriptBody } from "../threads/transcript.mjs";
15
15
  import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
16
- import { classifyReviewsForDisplay } from "../comments/review-visibility.mjs";
16
+ import { classifyReviewsForDisplay, classifyChangesRequestedReviewsForDisplay, } from "../comments/review-visibility.mjs";
17
17
  import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
18
18
  import { normalizeBotUsernames } from "../comments/authors.mjs";
19
19
  import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
@@ -75,7 +75,7 @@ export async function runCheck(opts) {
75
75
  else
76
76
  seenSummaries.push(r);
77
77
  }
78
- const changesRequestedReviewVisibility = classifyReviewsForDisplay(batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)), seenMap);
78
+ const changesRequestedReviewVisibility = classifyChangesRequestedReviewsForDisplay(batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)), seenMap, botUsernames);
79
79
  const approvedReviewVisibility = classifyReviewsForDisplay(batchData.approvedReviews, seenMap);
80
80
  await Promise.allSettled([
81
81
  ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
@@ -48,7 +48,7 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
48
48
  surfacedApprovals: approvals,
49
49
  };
50
50
  }
51
- export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, _reviews, checks, prNumber, botUsernames = new Set(), ruleAutoResolveThreadIds = []) {
51
+ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber, botUsernames = new Set(), ruleAutoResolveThreadIds = []) {
52
52
  const allThreads = [...threads, ...resolutionOnlyThreads];
53
53
  const replyThreadIds = dedupeIds(allThreads
54
54
  .filter((t) => isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames))
@@ -60,21 +60,36 @@ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentId
60
60
  .map((t) => t.id),
61
61
  ...ruleAutoResolveThreadIds,
62
62
  ]);
63
+ // Bot/non-human CHANGES_REQUESTED reviews are auto-dismissed after the agent pushes a fix.
64
+ // Human reviews are left for the reviewer to re-review or dismiss themselves.
65
+ const dismissReviewIds = dedupeIds(reviews
66
+ .filter((r) => !isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames))
67
+ .map((r) => r.id));
63
68
  const hasReply = replyThreadIds.length > 0;
69
+ const hasDismiss = dismissReviewIds.length > 0;
70
+ // Mutations that require --message: replies (to human threads) and dismissals (of bot CR reviews).
71
+ const hasMessageMutations = hasReply || hasDismiss;
64
72
  const hasResolveOrMinimize = resolveThreadIds.length > 0 || allCommentIds.length > 0;
65
- if (hasReply && hasResolveOrMinimize) {
66
- // Split: reply command needs SHA; resolve/minimize command does not.
73
+ if (hasMessageMutations && hasResolveOrMinimize) {
74
+ // Split: message-bearing mutations (replies + dismissals) ride in resolveArgv;
75
+ // resolve/minimize mutations go in resolveOnlyArgv so they can run without SHA or message.
67
76
  const resolveArgv = buildPrShepherdCommand(["resolve", String(prNumber)]).argv;
68
- resolveArgv.push("--reply-thread-ids", replyThreadIds.join(","));
77
+ if (replyThreadIds.length > 0) {
78
+ resolveArgv.push("--reply-thread-ids", replyThreadIds.join(","));
79
+ }
69
80
  resolveArgv.push("--message", "$DISMISS_MESSAGE");
70
- // `requiresHeadSha` is only true when actionable thread fixes or failing
71
- // checks are being addressed — mutations that can race with a moving HEAD.
72
- const requiresHeadSha = threads.length > 0 || checks.length > 0;
81
+ if (hasDismiss) {
82
+ resolveArgv.push("--dismiss-review-ids", dismissReviewIds.join(","));
83
+ }
84
+ // SHA is required when actionable thread fixes or failing checks are being addressed,
85
+ // or when bot CR reviews are being dismissed (post-push SHA gate).
86
+ const requiresHeadSha = threads.length > 0 || checks.length > 0 || hasDismiss;
73
87
  const resolveCommand = {
74
88
  argv: resolveArgv,
75
89
  requiresHeadSha,
76
90
  requiresDismissMessage: true,
77
- replyThreadIds,
91
+ ...(replyThreadIds.length > 0 ? { replyThreadIds } : undefined),
92
+ ...(hasDismiss ? { dismissReviewIds } : undefined),
78
93
  hasMutations: true,
79
94
  };
80
95
  const resolveOnlyArgv = buildPrShepherdCommand(["resolve", String(prNumber)]).argv;
@@ -93,7 +108,7 @@ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentId
93
108
  };
94
109
  return { resolveCommand, resolveOnlyCommand };
95
110
  }
96
- // Single command: either reply-only or resolve/minimize-only (no split needed).
111
+ // Single command: all mutations combined (or only one category present).
97
112
  const argv = buildPrShepherdCommand(["resolve", String(prNumber)]).argv;
98
113
  if (replyThreadIds.length > 0) {
99
114
  argv.push("--reply-thread-ids", replyThreadIds.join(","));
@@ -105,16 +120,23 @@ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentId
105
120
  if (allCommentIds.length > 0) {
106
121
  argv.push("--minimize-comment-ids", allCommentIds.join(","));
107
122
  }
108
- const hasMutations = hasReply || hasResolveOrMinimize;
109
- // SHA is only required when replying after actionable fixes or failing checks.
110
- // Resolve/minimize-only mutations never need SHA.
111
- const requiresHeadSha = hasReply && (threads.length > 0 || checks.length > 0);
123
+ if (hasDismiss) {
124
+ // Add --message when dismissing without a reply (replies already added it above).
125
+ if (!hasReply)
126
+ argv.push("--message", "$DISMISS_MESSAGE");
127
+ argv.push("--dismiss-review-ids", dismissReviewIds.join(","));
128
+ }
129
+ const hasMutations = hasMessageMutations || hasResolveOrMinimize;
130
+ // SHA is required when replying after actionable fixes/checks, or whenever dismissing
131
+ // (dismissal is a post-push operation that must race-check against a moving HEAD).
132
+ const requiresHeadSha = hasDismiss || (hasReply && (threads.length > 0 || checks.length > 0));
112
133
  const resolveCommand = {
113
134
  argv,
114
135
  requiresHeadSha,
115
- requiresDismissMessage: replyThreadIds.length > 0,
136
+ requiresDismissMessage: hasMessageMutations,
116
137
  ...(replyThreadIds.length > 0 ? { replyThreadIds } : undefined),
117
138
  ...(resolveThreadIds.length > 0 ? { resolveThreadIds } : undefined),
139
+ ...(hasDismiss ? { dismissReviewIds } : undefined),
118
140
  hasMutations,
119
141
  };
120
142
  return { resolveCommand };
@@ -123,5 +123,9 @@ export function buildEscalateSuggestion(triggers, detail) {
123
123
  if (triggers.includes("thread-missing-location")) {
124
124
  return "Review thread has no file/line reference — automated location routing failed and manual handling is required.";
125
125
  }
126
+ if (triggers.includes("bot-cr-not-dismissed")) {
127
+ const ids = detail ? ` (review IDs: ${detail})` : "";
128
+ return `Bot CHANGES_REQUESTED review(s) remained undismissed past the stall window${ids}. The agent likely dropped \`--dismiss-review-ids\` from a prior resolve command. Dismiss the review(s) manually (or re-run resolve with the IDs) to unblock the PR.`;
129
+ }
126
130
  return "Ambiguous state — automated handling cannot proceed safely. Inspect the PR and act manually.";
127
131
  }
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable max-lines */
2
2
  import { readFixAttempts, writeFixAttempts, } from "../../state/fix-attempts.mjs";
3
+ import { readBotCrSeenState, writeBotCrSeenState, updateBotCrSeenState, } from "../../state/bot-cr-seen.mjs";
3
4
  import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
4
5
  import { hashBody, markSeen } from "../../state/seen-comments.mjs";
5
6
  import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
@@ -9,6 +10,7 @@ import { applyStallGuard } from "./stall.mjs";
9
10
  import { tryCancelRun, buildInProgressRunIds } from "./helpers.mjs";
10
11
  import { annotationMarkerBody } from "../check-annotations.mjs";
11
12
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
13
+ import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
12
14
  function nextFixAttempts(stored, headSha, threads) {
13
15
  const threadAttempts = stored ? { ...stored.threadAttempts } : {};
14
16
  const threadBodyHashes = stored?.threadBodyHashes
@@ -29,6 +31,31 @@ export async function handleFixCode(ctx) {
29
31
  const failingChecks = report.checks.failing;
30
32
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
31
33
  const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
34
+ const botCrReviews = report.changesRequestedReviews.filter((r) => !isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames));
35
+ const botCrStateKey = { owner: repoOwner, repo: repoName, pr: prNumber };
36
+ const previousBotCrState = await readBotCrSeenState(botCrStateKey);
37
+ const nowSeconds = Math.floor(Date.now() / 1000);
38
+ const { next: nextBotCrState, staleIds: staleBotCrIds } = updateBotCrSeenState(previousBotCrState, botCrReviews, nowSeconds, stallTimeoutSeconds);
39
+ await writeBotCrSeenState(botCrStateKey, nextBotCrState);
40
+ if (staleBotCrIds.length > 0) {
41
+ const staleSet = new Set(staleBotCrIds);
42
+ const staleReviews = botCrReviews.filter((r) => staleSet.has(r.id));
43
+ const escalateBase = {
44
+ triggers: ["bot-cr-not-dismissed"],
45
+ unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
46
+ ambiguousComments: report.comments.actionable.map(toAgentComment),
47
+ changesRequestedReviews: staleReviews,
48
+ suggestion: buildEscalateSuggestion(["bot-cr-not-dismissed"], staleBotCrIds.join(", ")),
49
+ };
50
+ return {
51
+ ...base,
52
+ action: "escalate",
53
+ escalate: {
54
+ ...escalateBase,
55
+ humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
56
+ },
57
+ };
58
+ }
32
59
  const escalateTriggers = checkEscalateTriggers(report.threads.actionable, threadAttempts);
33
60
  if (escalateTriggers.triggers.length > 0) {
34
61
  const escalateBase = {
@@ -2,19 +2,12 @@ import { renderShellCommand } from "../../cli/runner.mjs";
2
2
  import { buildFailingCheckInstructions } from "./check-instructions.mjs";
3
3
  import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
4
4
  import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
5
- // Module-private: the final "stop" step appended to every fix_code instruction list. No longer
6
- // re-exported now that the recheck/delay suffix that consumed it (in iterate-instructions) is gone.
7
5
  const FIX_INSTRUCTION_STOP = "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.";
8
- /**
9
- * Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE`, `$HEAD_SHA`, and
10
- * whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is never in `argv` —
11
- * `renderResolveCommand` appends `--require-sha "$HEAD_SHA"` when `requiresHeadSha` is set.
12
- */
6
+ /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
13
7
  export function renderResolveCommand(rc) {
14
8
  const parts = [...rc.argv];
15
- if (rc.requiresHeadSha) {
9
+ if (rc.requiresHeadSha)
16
10
  parts.push("--require-sha", "$HEAD_SHA");
17
- }
18
11
  return renderShellCommand(parts);
19
12
  }
20
13
  export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, _baseBranch, // retained for call-site stability; rebase mechanics now defer to the caller
@@ -79,7 +72,12 @@ resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], f
79
72
  instructions.push(`For each item under \`## Check annotations\`: inspect the referenced file range and decide whether the annotation requires a code change. These annotations are surfaced once per PR and do not need any resolve/minimize mutation.`);
80
73
  }
81
74
  if (changesRequestedReviews.length > 0) {
82
- instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
75
+ const staleClause = changesRequestedReviews.some((r) => r.staleBotCr)
76
+ ? " Bullets tagged `[pending dismissal — already surfaced]` are bot CR reviews you saw on a previous tick; the CLI hides re-surfaced bodies to keep output lean — re-read the prior tick if you need the body."
77
+ : "";
78
+ instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.${staleClause}`);
79
+ if ((resolveCommand.dismissReviewIds?.length ?? 0) > 0)
80
+ instructions.push(`Pass every ID listed in \`--dismiss-review-ids\` to the \`resolve:\` command verbatim — these are bot/non-human CR reviews that the agent (not the author) must dismiss. Dropping an ID leaves the PR in \`CHANGES_REQUESTED\` state; the next tick re-surfaces it as \`[pending dismissal]\` and an unattended bot CR escalates after \`iterate.stallTimeoutMinutes\`.`);
83
81
  }
84
82
  if (resolveOnlyCommand?.hasMutations)
85
83
  instructions.push(`Run the \`resolve-only:\` command shown above — no substitutions needed.`);
@@ -42,6 +42,20 @@ function writeQuietStatus(tick, elapsedSeconds, sleepSeconds, result) {
42
42
  process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT ${result.status}/${result.mergeStateStatus}/${result.reviewDecision ?? "NO_REVIEW_DECISION"}${active}${commitSeg}${reviewRoundSeg}${reviewSeg} — sleeping ${sleepSeconds}s\n`);
43
43
  }
44
44
  const MAX_TIMER_MS = 2 ** 31 - 1;
45
+ const TIMER_DRIFT_TOLERANCE_MS = 500;
46
+ function writeWaitProgress(opts) {
47
+ const elapsedSeconds = Math.round(opts.elapsedMs / 1000);
48
+ const sleepSeconds = Math.round(opts.sleepMs / 1000);
49
+ if (!opts.quietStatus) {
50
+ writeTickProgress(opts.tick, elapsedSeconds, sleepSeconds, opts.verbose);
51
+ return opts.lastWaitSignature;
52
+ }
53
+ const signature = waitSignature(opts.result);
54
+ if (signature !== opts.lastWaitSignature) {
55
+ writeQuietStatus(opts.tick, elapsedSeconds, sleepSeconds, opts.result);
56
+ }
57
+ return signature;
58
+ }
45
59
  export async function runPoll(opts) {
46
60
  const { intervalSeconds, timeoutSeconds, ...iterateOpts } = opts;
47
61
  const intervalMs = Math.min(intervalSeconds * 1000, MAX_TIMER_MS);
@@ -62,19 +76,20 @@ export async function runPoll(opts) {
62
76
  const remainingMs = timeoutMs - elapsedMs;
63
77
  if (remainingMs <= 0)
64
78
  break;
65
- const nextSleepMs = Math.min(intervalMs, remainingMs);
66
- if (quietStatus) {
67
- const signature = waitSignature(lastResult);
68
- if (signature !== lastWaitSignature) {
69
- writeQuietStatus(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), lastResult);
70
- }
71
- lastWaitSignature = signature;
72
- }
73
- else {
74
- writeTickProgress(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), verbose);
75
- if (!verbose)
76
- dotsPrinted = true;
77
- }
79
+ if (remainingMs + TIMER_DRIFT_TOLERANCE_MS < intervalMs)
80
+ break;
81
+ const nextSleepMs = intervalMs;
82
+ lastWaitSignature = writeWaitProgress({
83
+ tick,
84
+ elapsedMs,
85
+ sleepMs: nextSleepMs,
86
+ result: lastResult,
87
+ quietStatus,
88
+ verbose,
89
+ lastWaitSignature,
90
+ });
91
+ if (!quietStatus && !verbose)
92
+ dotsPrinted = true;
78
93
  await sleep(nextSleepMs);
79
94
  }
80
95
  if (dotsPrinted)
@@ -1,4 +1,5 @@
1
1
  import { classifyItem } from "../state/seen-comments.mjs";
2
+ import { isHumanAuthor, isConfiguredBotAuthor } from "./authors.mjs";
2
3
  export function classifyReviewsForDisplay(reviews, seenMap) {
3
4
  const visible = [];
4
5
  const toMarkSeen = [];
@@ -12,3 +13,49 @@ export function classifyReviewsForDisplay(reviews, seenMap) {
12
13
  }
13
14
  return { visible, toMarkSeen };
14
15
  }
16
+ /**
17
+ * Bot CHANGES_REQUESTED reviews are different from every other surfaceable
18
+ * item: the agent — not the author — is responsible for dismissing them via
19
+ * `--dismiss-review-ids` after a fix push. If the agent forgets, the review
20
+ * stays in `CHANGES_REQUESTED` state and silently blocks the PR.
21
+ *
22
+ * The standard seen-gate (`classifyReviewsForDisplay`) suppresses items with
23
+ * unchanged bodies, which would also drop the bot CR ID from the
24
+ * `--dismiss-review-ids` flag — making the bug irrecoverable. This function
25
+ * keeps every bot CR in the visible set on every tick, using the seen-map
26
+ * only to pick the render form:
27
+ *
28
+ * - `new` / `edited` → full bullet (caller renders body normally).
29
+ * - `unchanged` → flagged `staleBotCr: true` so the formatter emits a terse
30
+ * one-line reminder.
31
+ *
32
+ * Human-authored CR reviews continue to flow through the standard seen-gate.
33
+ */
34
+ export function classifyChangesRequestedReviewsForDisplay(reviews, seenMap, botUsernames) {
35
+ const visible = [];
36
+ const toMarkSeen = [];
37
+ for (const review of reviews) {
38
+ const isBot = !isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames);
39
+ const cls = classifyItem(review.id, review.body, seenMap);
40
+ if (isBot) {
41
+ if (cls === "unchanged") {
42
+ visible.push({ ...review, staleBotCr: true });
43
+ }
44
+ else if (cls === "edited") {
45
+ visible.push({ ...review, edited: true });
46
+ toMarkSeen.push(review);
47
+ }
48
+ else {
49
+ visible.push(review);
50
+ toMarkSeen.push(review);
51
+ }
52
+ continue;
53
+ }
54
+ if (cls === "unchanged")
55
+ continue;
56
+ const rendered = cls === "edited" ? { ...review, edited: true } : review;
57
+ visible.push(rendered);
58
+ toMarkSeen.push(review);
59
+ }
60
+ return { visible, toMarkSeen };
61
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Persistent first-seen state for bot CHANGES_REQUESTED reviews.
3
+ *
4
+ * Bot CRs are auto-dismissed via `--dismiss-review-ids` in the post-push
5
+ * `resolve:` command. If the agent drops that flag, the bot CR keeps the PR in
6
+ * `CHANGES_REQUESTED` state. This file tracks when each bot CR was first
7
+ * observed so the iterate loop can escalate after `iterate.stallTimeoutMinutes`
8
+ * — independent of the broader fingerprint-based `stall-timeout` mechanism in
9
+ * `iterate-stall.mts` (which only fires when no field of the iterate result
10
+ * changed).
11
+ *
12
+ * State lives in
13
+ * `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/bot-cr-seen.json`.
14
+ *
15
+ * `bodyHash` lets us reset `firstSeenAt` when the bot re-issues the review
16
+ * with a different body, so a fresh review gets the full timeout window.
17
+ */
18
+ import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
19
+ import { randomUUID } from "node:crypto";
20
+ import { join, dirname } from "node:path";
21
+ import { SAFE_SEGMENT } from "../util/path-segment.mjs";
22
+ import { resolveStateBase } from "./base.mjs";
23
+ import { hashBody } from "./seen-comments.mjs";
24
+ export async function readBotCrSeenState(key) {
25
+ try {
26
+ const raw = await readFile(resolvePath(key), "utf8");
27
+ const parsed = JSON.parse(raw);
28
+ if (parsed === null || typeof parsed !== "object")
29
+ return null;
30
+ const reviews = parsed["reviews"];
31
+ if (reviews === null || typeof reviews !== "object" || Array.isArray(reviews))
32
+ return null;
33
+ const validated = {};
34
+ for (const [id, entry] of Object.entries(reviews)) {
35
+ if (entry === null || typeof entry !== "object")
36
+ continue;
37
+ const e = entry;
38
+ if (typeof e["firstSeenAt"] !== "number" || !Number.isFinite(e["firstSeenAt"]))
39
+ continue;
40
+ if (typeof e["bodyHash"] !== "string")
41
+ continue;
42
+ validated[id] = { firstSeenAt: e["firstSeenAt"], bodyHash: e["bodyHash"] };
43
+ }
44
+ return { reviews: validated };
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ export async function writeBotCrSeenState(key, state) {
51
+ let tmp;
52
+ try {
53
+ const path = resolvePath(key);
54
+ tmp = `${path}.${randomUUID()}.tmp`;
55
+ await mkdir(dirname(path), { recursive: true });
56
+ await writeFile(tmp, JSON.stringify(state), "utf8");
57
+ await rename(tmp, path);
58
+ tmp = undefined;
59
+ }
60
+ catch {
61
+ // best-effort
62
+ }
63
+ finally {
64
+ if (tmp !== undefined) {
65
+ try {
66
+ await unlink(tmp);
67
+ }
68
+ catch {
69
+ // best-effort cleanup
70
+ }
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * Update tracked entries against the current set of bot CR reviews:
76
+ * - Insert any new IDs with `firstSeenAt = now` and the current body hash.
77
+ * - Reset `firstSeenAt` for entries whose body hash changed (review re-issued).
78
+ * - Drop entries whose IDs are no longer in the input (review was dismissed
79
+ * or superseded by an approval).
80
+ *
81
+ * Pure function: returns the next state and the IDs whose age has reached the
82
+ * `stallTimeoutSeconds` threshold (escalate candidates).
83
+ */
84
+ export function updateBotCrSeenState(previous, currentBotCrReviews, nowSeconds, stallTimeoutSeconds) {
85
+ const previousReviews = previous?.reviews ?? {};
86
+ const next = {};
87
+ const staleIds = [];
88
+ for (const r of currentBotCrReviews) {
89
+ const bodyHash = hashBody(r.body);
90
+ const prior = previousReviews[r.id];
91
+ const entry = prior && prior.bodyHash === bodyHash ? prior : { firstSeenAt: nowSeconds, bodyHash };
92
+ next[r.id] = entry;
93
+ if (stallTimeoutSeconds > 0 && nowSeconds - entry.firstSeenAt >= stallTimeoutSeconds) {
94
+ staleIds.push(r.id);
95
+ }
96
+ }
97
+ return { next: { reviews: next }, staleIds };
98
+ }
99
+ function resolvePath(key) {
100
+ for (const [field, value] of [
101
+ ["owner", key.owner],
102
+ ["repo", key.repo],
103
+ ]) {
104
+ if (!SAFE_SEGMENT.test(value)) {
105
+ throw new Error(`Invalid state key segment "${field}": ${value}`);
106
+ }
107
+ }
108
+ const base = resolveStateBase();
109
+ return join(base, `${key.owner}-${key.repo}`, String(key.pr), "bot-cr-seen.json");
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.26.2",
3
+ "version": "0.27.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -39,7 +39,7 @@
39
39
  "@vitest/coverage-v8": "^4.1.4",
40
40
  "husky": "^9.1.7",
41
41
  "knip": "^6.14.1",
42
- "oxfmt": "^0.51.0",
42
+ "oxfmt": "^0.52.0",
43
43
  "oxlint": "^1.60.0",
44
44
  "typescript": "^6.0.3",
45
45
  "vitest": "^4.1.4"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.26.2",
3
+ "version": "0.27.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -16,7 +16,7 @@ Poll dispatcher for iterating a PR to completion.
16
16
 
17
17
  1. **Resolve the PR number** (`$N`): use the number or URL in `$ARGUMENTS`; otherwise infer it with `gh pr view --json number --jq .number`. If none is found, report an error and stop.
18
18
 
19
- 2. **Define the poll command once:** `pr-shepherd $N --interval 45s --timeout 4m`. Do not forward `$ARGUMENTS` as extra flags. Run `pr-shepherd --help` to inspect supported options.
19
+ 2. **Define the poll command once:** `pr-shepherd $N --interval 60s --timeout 4.5m`. Do not forward `$ARGUMENTS` as extra flags. Run `pr-shepherd --help` to inspect supported options.
20
20
 
21
21
  3. **Loop:** Run the poll, print its full output, and follow its `## Instructions` section exactly. Then run the poll again. Repeat until the CLI emits `[CANCEL]` or `[ESCALATE]`, unless the human directs you to stop. Every other action (`[WAIT]`, `[MARK_READY]`, `[FIX_CODE]`) is non-terminal: do its instructions, then poll again. The poll already bounds each wait via `--interval`/`--timeout`; do not add manual `sleep`s between ticks.
22
22