pr-shepherd 0.26.3 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli/list-formatters.mjs +3 -0
- package/bin/commands/check.mjs +2 -2
- package/bin/commands/iterate/escalate.mjs +4 -0
- package/bin/commands/iterate/fix-code.mjs +27 -0
- package/bin/commands/iterate/render.mjs +8 -10
- package/bin/comments/review-visibility.mjs +47 -0
- package/bin/state/bot-cr-seen.mjs +110 -0
- package/package.json +2 -2
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
|
@@ -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
|
}
|
package/bin/commands/check.mjs
CHANGED
|
@@ -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 =
|
|
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)),
|
|
@@ -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
|
-
|
|
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.`);
|
|
@@ -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.
|
|
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.
|
|
42
|
+
"oxfmt": "^0.52.0",
|
|
43
43
|
"oxlint": "^1.60.0",
|
|
44
44
|
"typescript": "^6.0.3",
|
|
45
45
|
"vitest": "^4.1.4"
|