pr-shepherd 0.59.3 → 0.60.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/api.d.mts +1 -1
- package/bin/classify/standard/codecov-report.d.mts +1 -1
- package/bin/classify/standard/codecov-report.mjs +7 -2
- package/bin/classify/standard/coderabbit-notices.d.mts +4 -3
- package/bin/classify/standard/coderabbit-notices.mjs +29 -47
- package/bin/classify/standard/codex-notices.mjs +34 -7
- package/bin/classify/standard/dependabot-notices.mjs +5 -1
- package/bin/classify/standard/gemini-notices.d.mts +1 -1
- package/bin/classify/standard/gemini-notices.mjs +16 -5
- package/bin/classify/standard/kilo-no-issues.d.mts +1 -1
- package/bin/classify/standard/kilo-no-issues.mjs +37 -10
- package/bin/classify/standard/sourcery-notices.mjs +1 -1
- package/bin/cli/edited-comments-format.d.mts +14 -0
- package/bin/cli/edited-comments-format.mjs +39 -0
- package/bin/cli/fix-formatter.mjs +5 -2
- package/bin/cli/iterate-activity-formatter.d.mts +1 -1
- package/bin/cli/iterate-activity-formatter.mjs +4 -5
- package/bin/cli/iterate-check-names.d.mts +5 -0
- package/bin/cli/iterate-check-names.mjs +17 -0
- package/bin/cli/iterate-formatter.mjs +15 -19
- package/bin/cli/iterate-lean.mjs +19 -11
- package/bin/cli/iterate-verbose.mjs +7 -2
- package/bin/cli/list-formatters.d.mts +0 -3
- package/bin/cli/list-formatters.mjs +1 -5
- package/bin/commands/check-review-budget.d.mts +39 -0
- package/bin/commands/check-review-budget.mjs +78 -0
- package/bin/commands/check-wait-detectors.d.mts +6 -1
- package/bin/commands/check-wait-detectors.mjs +2 -1
- package/bin/commands/check.d.mts +2 -0
- package/bin/commands/check.mjs +46 -25
- package/bin/commands/iterate/check-instructions.d.mts +5 -0
- package/bin/commands/iterate/check-instructions.mjs +4 -0
- package/bin/commands/iterate/classify.d.mts +1 -1
- package/bin/commands/iterate/edited-comment-markers.d.mts +3 -0
- package/bin/commands/iterate/edited-comment-markers.mjs +10 -0
- package/bin/commands/iterate/fix-code.mjs +17 -1
- package/bin/commands/iterate/helpers.mjs +9 -5
- package/bin/commands/iterate/index.mjs +44 -17
- package/bin/commands/iterate/render.mjs +1 -4
- package/bin/commands/poll-rate-limit-cancel.mjs +1 -1
- package/bin/commands/poll-seen-options.d.mts +8 -0
- package/bin/commands/poll-seen-options.mjs +7 -0
- package/bin/commands/poll.mjs +7 -5
- package/bin/comments/review-output-budget.d.mts +32 -0
- package/bin/comments/review-output-budget.mjs +55 -0
- package/bin/comments/visible-comments.d.mts +3 -1
- package/bin/comments/visible-comments.mjs +10 -5
- package/bin/config/load.d.mts +6 -0
- package/bin/config/load.mjs +7 -0
- package/bin/config.json +2 -1
- package/bin/github/poll-summary-fingerprint.mjs +5 -1
- package/bin/github/poll-summary-review.mjs +2 -1
- package/bin/reporters/agent.d.mts +1 -3
- package/bin/reporters/agent.mjs +0 -1
- package/bin/types/escalate.d.mts +9 -0
- package/bin/types/iterate.d.mts +19 -1
- package/bin/types/report.d.mts +23 -4
- package/bin/util/cap-list.d.mts +7 -0
- package/bin/util/cap-list.mjs +13 -0
- package/package.json +2 -2
- package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
|
@@ -9,12 +9,13 @@ import { classifyReviewSummaries } from "./classify.mjs";
|
|
|
9
9
|
import { applyStallGuard } from "./stall.mjs";
|
|
10
10
|
import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
11
11
|
import { handleFixCode } from "./fix-code.mjs";
|
|
12
|
-
import {
|
|
12
|
+
import { normalizeBotUsernames } from "../../comments/authors.mjs";
|
|
13
13
|
import { autoMinimizeComments } from "../../comments/resolve.mjs";
|
|
14
14
|
import { hasCheckDrivenActionableWork } from "../check-annotations.mjs";
|
|
15
15
|
import { buildReadyMergeOutcome, handleActiveMergeState } from "./merge-state.mjs";
|
|
16
16
|
import { buildIterateBase } from "./base.mjs";
|
|
17
17
|
import { markReadyIfAuthorized } from "./mark-ready.mjs";
|
|
18
|
+
import { classifyItem, loadSeenMap } from "../../state/seen-comments.mjs";
|
|
18
19
|
import { withIterateApiUsage } from "./run.mjs";
|
|
19
20
|
import { fetchRawSummaryPr } from "../../github/poll-summary.mjs";
|
|
20
21
|
import { summarizePollSummaryPr } from "../../github/poll-summary-projector.mjs";
|
|
@@ -29,10 +30,35 @@ import { buildUnreportedFixResult, planUnreportedRequired } from "./unreported-r
|
|
|
29
30
|
import { createCheckExecutionContext } from "../check-execution-context.mjs";
|
|
30
31
|
import { transportReadinessGaps } from "../../github/transport-evidence.mjs";
|
|
31
32
|
import { buildEscalateHumanMessage } from "./escalate.mjs";
|
|
33
|
+
import { markEditedCommentsSeen } from "./edited-comment-markers.mjs";
|
|
32
34
|
export function runIterate(opts) {
|
|
33
|
-
return withIterateApiUsage(opts, () =>
|
|
35
|
+
return withIterateApiUsage(opts, async () => {
|
|
36
|
+
const tick = {};
|
|
37
|
+
return attachEditedComments(await runIterateCore(opts, tick), tick, opts);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
// Edited PR comments are not readiness work. Hold them while the PR waits or counts down, and show
|
|
41
|
+
// each once on any other action (including MERGE after the ready delay), writing its marker then.
|
|
42
|
+
// The poll loop defers the write to the result it returns, since it can discard or refresh a tick.
|
|
43
|
+
// A replayed cached report can carry edits already shown, so re-check the current markers.
|
|
44
|
+
async function attachEditedComments(result, tick, opts) {
|
|
45
|
+
const candidates = tick.report?.comments.edited ?? [];
|
|
46
|
+
if (candidates.length === 0 || result.action === "wait" || result.action === "ready")
|
|
47
|
+
return result;
|
|
48
|
+
if (result.action === "cancel" && (result.reason === "merged" || result.reason === "closed"))
|
|
49
|
+
return result;
|
|
50
|
+
const [owner, repo] = result.repo.split("/");
|
|
51
|
+
if (!owner || !repo)
|
|
52
|
+
return result;
|
|
53
|
+
const key = { owner, repo, pr: result.pr };
|
|
54
|
+
const seen = await loadSeenMap(key);
|
|
55
|
+
const edited = candidates.filter((c) => classifyItem(c.id, c.body, seen) === "edited");
|
|
56
|
+
if (edited.length === 0)
|
|
57
|
+
return result;
|
|
58
|
+
const withEdits = { ...result, editedComments: edited };
|
|
59
|
+
return opts.deferEditedCommentMarkers ? withEdits : markEditedCommentsSeen(withEdits);
|
|
34
60
|
}
|
|
35
|
-
async function runIterateCore(opts) {
|
|
61
|
+
async function runIterateCore(opts, tick) {
|
|
36
62
|
const config = loadConfig();
|
|
37
63
|
const botUsernames = normalizeBotUsernames(config.botUsernames);
|
|
38
64
|
const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
|
|
@@ -51,7 +77,9 @@ async function runIterateCore(opts) {
|
|
|
51
77
|
// compatibility key in the config loader.
|
|
52
78
|
autoResolve: true,
|
|
53
79
|
autoMinimizeSuppressed: config.actions.autoMinimizeSuppressed,
|
|
80
|
+
reviewOutputMaxChars: config.iterate.reviewOutputMaxChars,
|
|
54
81
|
}, checkContext);
|
|
82
|
+
tick.report = report;
|
|
55
83
|
const [repoOwner, repoName] = report.repo.split("/");
|
|
56
84
|
if (!repoOwner || !repoName) {
|
|
57
85
|
throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
|
|
@@ -79,7 +107,11 @@ async function runIterateCore(opts) {
|
|
|
79
107
|
firstLook: report.firstLookSummaries,
|
|
80
108
|
seen: report.reviewSummaries,
|
|
81
109
|
edited: report.editedSummaries,
|
|
82
|
-
}, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [
|
|
110
|
+
}, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [
|
|
111
|
+
...report.threads.actionable,
|
|
112
|
+
...report.threads.resolutionOnly,
|
|
113
|
+
...(report.heldThreadReviewIds ?? []).map((reviewId) => ({ reviewId })),
|
|
114
|
+
], report.ruleAutoResolveReviewSummaryIds);
|
|
83
115
|
// Minimize already-seen review summaries in-process so they never become agent-facing work.
|
|
84
116
|
// GitHub can still return a null/error/rate-limit result per ID without
|
|
85
117
|
// throwing (autoMinimizeComments reports this via `errors`, not a rejection);
|
|
@@ -94,20 +126,16 @@ async function runIterateCore(opts) {
|
|
|
94
126
|
if (unminimized.length > 0)
|
|
95
127
|
reviewSummaryIds = [...reviewSummaryIds, ...unminimized];
|
|
96
128
|
}
|
|
97
|
-
// A first look at an already-minimized comment is only an acknowledgment.
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
comment.edited === true &&
|
|
104
|
-
!isConfiguredBotAuthor(comment, botUsernames));
|
|
105
|
-
const hasReadinessWork = report.threads.actionable.length > 0 ||
|
|
129
|
+
// A first look at an already-minimized comment is only an acknowledgment. Edited PR comments
|
|
130
|
+
// (report.comments.edited) are neither: they never block readiness or restart the ready delay.
|
|
131
|
+
// Review items held back by iterate.reviewOutputMaxChars keep the PR out of ready/merge: a
|
|
132
|
+
// capped tick that shows only first-look comments still counts, since held work follows.
|
|
133
|
+
const hasReadinessWork = (report.reviewItems !== undefined && report.comments.firstLook.length > 0) ||
|
|
134
|
+
report.threads.actionable.length > 0 ||
|
|
106
135
|
report.threads.resolutionOnly.length > 0 ||
|
|
107
136
|
report.threads.firstLook.length > 0 ||
|
|
108
137
|
(report.threads.ruleAutoResolveIds?.length ?? 0) > 0 ||
|
|
109
138
|
report.comments.actionable.length > 0 ||
|
|
110
|
-
editedMinimizedFeedback ||
|
|
111
139
|
(report.comments.minimizeIds?.length ?? 0) > 0 ||
|
|
112
140
|
report.changesRequestedReviews.length > 0 ||
|
|
113
141
|
hasCheckDrivenActionableWork(reportForWork.checks, report.mergeStatus.status) ||
|
|
@@ -128,7 +156,7 @@ async function runIterateCore(opts) {
|
|
|
128
156
|
!hasReadinessWork &&
|
|
129
157
|
!activeMerge &&
|
|
130
158
|
staleAncestry === null;
|
|
131
|
-
const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork,
|
|
159
|
+
const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, checkContext);
|
|
132
160
|
const headSha = report.headSha ?? "unknown";
|
|
133
161
|
// The elapsed marker survives a hidden-comment acknowledgement tick and is
|
|
134
162
|
// consumed only when this tick cancels or merges. A receipt that is still
|
|
@@ -400,7 +428,7 @@ async function recordReadyReceipt(key, report, context) {
|
|
|
400
428
|
}
|
|
401
429
|
}
|
|
402
430
|
/** Clear a stale READY receipt; return whether a current receipt remains. */
|
|
403
|
-
async function revalidateReadyReceipt(key, report, hasReadinessWork,
|
|
431
|
+
async function revalidateReadyReceipt(key, report, hasReadinessWork, context) {
|
|
404
432
|
const receipt = await readReadyReceipt(key);
|
|
405
433
|
if (!receipt)
|
|
406
434
|
return false;
|
|
@@ -413,7 +441,6 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork, editedMinim
|
|
|
413
441
|
!report.headSha ||
|
|
414
442
|
!report.baseRefOid ||
|
|
415
443
|
transportReadinessGaps(report, report.mergeStatus.mergeStateStatus).length > 0 ||
|
|
416
|
-
editedMinimizedFeedback ||
|
|
417
444
|
(!retainQueuedReceipt && (report.status !== "READY" || hasReadinessWork))) {
|
|
418
445
|
await clearReadyReceipt(key);
|
|
419
446
|
return false;
|
|
@@ -49,10 +49,7 @@ instructionStyle = "inline") {
|
|
|
49
49
|
instructions.push("Review every item under `## First-look items` before acting.");
|
|
50
50
|
if (firstLookSummaries.length > 0)
|
|
51
51
|
instructions.push("Read each body under `## Review summaries (first look)` before acting.");
|
|
52
|
-
const editedTotal = editedSummaries.length +
|
|
53
|
-
actionableComments.filter((c) => c.edited).length +
|
|
54
|
-
firstLookThreads.filter((t) => t.edited).length +
|
|
55
|
-
firstLookComments.filter((c) => c.edited).length;
|
|
52
|
+
const editedTotal = editedSummaries.length + firstLookThreads.filter((t) => t.edited).length;
|
|
56
53
|
if (editedTotal > 0)
|
|
57
54
|
instructions.push("Read every item marked `[edited since first look]`, including edited summaries and edited first-look bullets, before deciding whether to resolve a matching thread.");
|
|
58
55
|
if (unlocatedThreads.length > 0)
|
|
@@ -27,7 +27,7 @@ export function onePrCancelFromPulls(explicit, repoSlug, pulls) {
|
|
|
27
27
|
isDraft: pull.draft,
|
|
28
28
|
shouldCancel: true,
|
|
29
29
|
remainingSeconds: 0,
|
|
30
|
-
summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0, superseded: 0 },
|
|
30
|
+
summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0, queued: 0, superseded: 0 },
|
|
31
31
|
baseBranch: pull.baseRefName,
|
|
32
32
|
branchProtection: null,
|
|
33
33
|
checks: [],
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seen-state options for one poll tick. The loop can discard or refresh a tick's result, so
|
|
3
|
+
* edited-comment markers are written only for the result it returns.
|
|
4
|
+
*/
|
|
5
|
+
export declare function pollTickSeenOptions(debounceSeconds: number, pastDebounce: boolean): {
|
|
6
|
+
persistSeen: boolean;
|
|
7
|
+
deferEditedCommentMarkers: boolean;
|
|
8
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seen-state options for one poll tick. The loop can discard or refresh a tick's result, so
|
|
3
|
+
* edited-comment markers are written only for the result it returns.
|
|
4
|
+
*/
|
|
5
|
+
export function pollTickSeenOptions(debounceSeconds, pastDebounce) {
|
|
6
|
+
return { persistSeen: debounceSeconds === 0 || pastDebounce, deferEditedCommentMarkers: true };
|
|
7
|
+
}
|
package/bin/commands/poll.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { runIterate } from "./iterate/index.mjs";
|
|
2
|
+
import { pollTickSeenOptions } from "./poll-seen-options.mjs";
|
|
3
|
+
import { markEditedCommentsSeen } from "./iterate/edited-comment-markers.mjs";
|
|
2
4
|
import { sleep } from "../util/sleep.mjs";
|
|
3
5
|
import { withPollApiUsage } from "./poll-run.mjs";
|
|
4
6
|
import { loadConfig } from "../config/load.mjs";
|
|
@@ -26,6 +28,7 @@ async function runPollCore(opts) {
|
|
|
26
28
|
const verbose = opts.verbose === true;
|
|
27
29
|
const quietStatus = quietStatusOpt === true;
|
|
28
30
|
const untilTerminal = untilTerminalOpt === true;
|
|
31
|
+
const swallowsMarkReady = untilTerminal || iterateOpts.merge === true;
|
|
29
32
|
let lastWaitSignature = null;
|
|
30
33
|
let pendingQuotaWarning;
|
|
31
34
|
let prNumber = opts.prNumber;
|
|
@@ -34,6 +37,7 @@ async function runPollCore(opts) {
|
|
|
34
37
|
while (true) {
|
|
35
38
|
tick += 1;
|
|
36
39
|
const pastDebounce = debounceUntil !== null && Date.now() >= debounceUntil;
|
|
40
|
+
const seenOptions = pollTickSeenOptions(debounceSeconds, pastDebounce);
|
|
37
41
|
const remainingBefore = untilTerminal
|
|
38
42
|
? Number.POSITIVE_INFINITY
|
|
39
43
|
: timeoutMs - (Date.now() - start);
|
|
@@ -41,7 +45,7 @@ async function runPollCore(opts) {
|
|
|
41
45
|
const iterateTick = (fingerprintCache) => runIterate({
|
|
42
46
|
...iterateOpts,
|
|
43
47
|
prNumber,
|
|
44
|
-
|
|
48
|
+
...seenOptions,
|
|
45
49
|
fingerprintCache,
|
|
46
50
|
deferQuotaWarning: !untilTerminal,
|
|
47
51
|
quotaWarningMinimumPollIntervalMinutes: intervalSeconds / 60,
|
|
@@ -123,9 +127,7 @@ async function runPollCore(opts) {
|
|
|
123
127
|
await sleep(sleepMs);
|
|
124
128
|
continue;
|
|
125
129
|
}
|
|
126
|
-
if (
|
|
127
|
-
lastResult.action === "mark_ready" &&
|
|
128
|
-
!pastDebounce) {
|
|
130
|
+
if (swallowsMarkReady && lastResult.action === "mark_ready" && !pastDebounce) {
|
|
129
131
|
debounceUntil = null;
|
|
130
132
|
const elapsedMs = Date.now() - start;
|
|
131
133
|
const sleepMs = quotaPollIntervalMs(quotaBands, lastResult.apiUsage, intervalMs, MAX_TIMER_MS, getGithubTransport());
|
|
@@ -153,5 +155,5 @@ async function runPollCore(opts) {
|
|
|
153
155
|
await refreshIfReturning();
|
|
154
156
|
break;
|
|
155
157
|
}
|
|
156
|
-
return lastResult;
|
|
158
|
+
return markEditedCommentsSeen(lastResult);
|
|
157
159
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tick output budget for review items (see `iterate.reviewOutputMaxChars`).
|
|
3
|
+
*
|
|
4
|
+
* Fetching is complete; only the rendered volume is capped. When the bodies of every surfaced
|
|
5
|
+
* review item fit the budget, nothing changes. Otherwise items are taken in a fixed priority
|
|
6
|
+
* order — group by group, oldest first within a group — until the next item would exceed the
|
|
7
|
+
* budget (at least one item is always shown). Every other budgeted item is held: the caller
|
|
8
|
+
* omits it from the report, from generated mutation IDs, and from seen-marker writes, so a
|
|
9
|
+
* later tick shows it.
|
|
10
|
+
*/
|
|
11
|
+
/** One review item's identity, age, and rendered-body cost in characters. */
|
|
12
|
+
export interface BudgetItem {
|
|
13
|
+
id: string;
|
|
14
|
+
createdAtUnix?: number;
|
|
15
|
+
cost: number;
|
|
16
|
+
}
|
|
17
|
+
/** Raw rollup emitted only when items were held: `shown` of `total` review items this tick. */
|
|
18
|
+
export interface ReviewItemRollup {
|
|
19
|
+
shown: number;
|
|
20
|
+
total: number;
|
|
21
|
+
}
|
|
22
|
+
export interface ReviewOutputSelection {
|
|
23
|
+
/** False only for a budgeted item held back this tick. Unbudgeted IDs are always kept. */
|
|
24
|
+
keep: (id: string) => boolean;
|
|
25
|
+
rollup?: ReviewItemRollup;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Select which review items to show. `groups` is in priority order; an ID that appears in more
|
|
29
|
+
* than one group (e.g. a thread that is both resolution-only and first-look) is counted and
|
|
30
|
+
* charged once, in its first group. `maxChars <= 0` disables the cap.
|
|
31
|
+
*/
|
|
32
|
+
export declare function selectReviewOutput(groups: ReadonlyArray<ReadonlyArray<BudgetItem>>, maxChars: number): ReviewOutputSelection;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tick output budget for review items (see `iterate.reviewOutputMaxChars`).
|
|
3
|
+
*
|
|
4
|
+
* Fetching is complete; only the rendered volume is capped. When the bodies of every surfaced
|
|
5
|
+
* review item fit the budget, nothing changes. Otherwise items are taken in a fixed priority
|
|
6
|
+
* order — group by group, oldest first within a group — until the next item would exceed the
|
|
7
|
+
* budget (at least one item is always shown). Every other budgeted item is held: the caller
|
|
8
|
+
* omits it from the report, from generated mutation IDs, and from seen-marker writes, so a
|
|
9
|
+
* later tick shows it.
|
|
10
|
+
*/
|
|
11
|
+
const KEEP_ALL = { keep: () => true };
|
|
12
|
+
function oldestFirst(items) {
|
|
13
|
+
// Unknown ages sort last; Array.prototype.sort is stable, so fetch order breaks ties.
|
|
14
|
+
const age = (item) => item.createdAtUnix ?? Number.MAX_SAFE_INTEGER;
|
|
15
|
+
return [...items].sort((a, b) => age(a) - age(b));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Select which review items to show. `groups` is in priority order; an ID that appears in more
|
|
19
|
+
* than one group (e.g. a thread that is both resolution-only and first-look) is counted and
|
|
20
|
+
* charged once, in its first group. `maxChars <= 0` disables the cap.
|
|
21
|
+
*/
|
|
22
|
+
export function selectReviewOutput(groups, maxChars) {
|
|
23
|
+
if (maxChars <= 0)
|
|
24
|
+
return KEEP_ALL;
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const ordered = [];
|
|
27
|
+
for (const group of groups) {
|
|
28
|
+
const unique = group.filter((item) => {
|
|
29
|
+
if (seen.has(item.id))
|
|
30
|
+
return false;
|
|
31
|
+
seen.add(item.id);
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
ordered.push(...oldestFirst(unique));
|
|
35
|
+
}
|
|
36
|
+
const totalCost = ordered.reduce((sum, item) => sum + item.cost, 0);
|
|
37
|
+
if (totalCost <= maxChars)
|
|
38
|
+
return KEEP_ALL;
|
|
39
|
+
const shown = new Set();
|
|
40
|
+
let used = 0;
|
|
41
|
+
for (const item of ordered) {
|
|
42
|
+
if (shown.size > 0 && used + item.cost > maxChars)
|
|
43
|
+
break;
|
|
44
|
+
shown.add(item.id);
|
|
45
|
+
used += item.cost;
|
|
46
|
+
}
|
|
47
|
+
const held = new Set(ordered.filter((item) => !shown.has(item.id)).map((item) => item.id));
|
|
48
|
+
// The at-least-one rule can show every item despite the budget: nothing is held, no rollup.
|
|
49
|
+
if (held.size === 0)
|
|
50
|
+
return KEEP_ALL;
|
|
51
|
+
return {
|
|
52
|
+
keep: (id) => !held.has(id),
|
|
53
|
+
rollup: { shown: shown.size, total: ordered.length },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { type SeenMarker } from "../state/seen-comments.mts";
|
|
2
2
|
import type { MinimizeCommentsPolicy } from "../config/load.mts";
|
|
3
|
-
import type { ActionableComment, PrComment } from "../types.mts";
|
|
3
|
+
import type { ActionableComment, EditedComment, PrComment } from "../types.mts";
|
|
4
4
|
import type { NormalizedBotUsernames } from "./authors.mts";
|
|
5
5
|
interface VisibleCommentClassification {
|
|
6
6
|
actionable: ActionableComment[];
|
|
7
7
|
minimizeIds: string[];
|
|
8
8
|
toMarkSeen: ActionableComment[];
|
|
9
|
+
/** Seen comments whose body changed. Not readiness work; iterate surfaces and marks them. */
|
|
10
|
+
edited: EditedComment[];
|
|
9
11
|
}
|
|
10
12
|
export declare function classifyVisibleComments(comments: PrComment[], seenMap: Map<string, SeenMarker>, minimizeComments: MinimizeCommentsPolicy | undefined, botUsernames?: NormalizedBotUsernames, deniedMinimizeIds?: ReadonlySet<string>): VisibleCommentClassification;
|
|
11
13
|
export {};
|
|
@@ -5,7 +5,14 @@ export function classifyVisibleComments(comments, seenMap, minimizeComments, bot
|
|
|
5
5
|
const actionable = [];
|
|
6
6
|
const minimizeIds = [];
|
|
7
7
|
const toMarkSeen = [];
|
|
8
|
+
const edited = [];
|
|
8
9
|
for (const c of comments.filter((comment) => !comment.isMinimized)) {
|
|
10
|
+
const cls = classifyItem(c.id, c.body, seenMap);
|
|
11
|
+
// An edit is never readiness work, even when the comment is eligible for minimization.
|
|
12
|
+
if (cls === "edited") {
|
|
13
|
+
edited.push({ ...c, edited: true });
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
9
16
|
if (!deniedMinimizeIds.has(c.id) &&
|
|
10
17
|
canGenerateGithubMutation(c.viewerCanMinimize, "minimize") &&
|
|
11
18
|
shouldMinimizeAuthor(c.authorType, minimizeComments, c.author, botUsernames)) {
|
|
@@ -14,12 +21,10 @@ export function classifyVisibleComments(comments, seenMap, minimizeComments, bot
|
|
|
14
21
|
toMarkSeen.push(c); // prevents re-surfacing as first-look after GitHub marks it minimized
|
|
15
22
|
continue;
|
|
16
23
|
}
|
|
17
|
-
const cls = classifyItem(c.id, c.body, seenMap);
|
|
18
24
|
if (cls === "unchanged")
|
|
19
25
|
continue;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
toMarkSeen.push(comment);
|
|
26
|
+
actionable.push(c);
|
|
27
|
+
toMarkSeen.push(c);
|
|
23
28
|
}
|
|
24
|
-
return { actionable, minimizeIds, toMarkSeen };
|
|
29
|
+
return { actionable, minimizeIds, toMarkSeen, edited };
|
|
25
30
|
}
|
package/bin/config/load.d.mts
CHANGED
|
@@ -74,6 +74,12 @@ export interface PrShepherdConfig {
|
|
|
74
74
|
* (default) omits the hint entirely; the CLI never prescribes rebase/merge mechanics itself.
|
|
75
75
|
*/
|
|
76
76
|
behindBaseHint: string;
|
|
77
|
+
/**
|
|
78
|
+
* Per-tick budget for review-item bodies in `fix_code` output, in characters. Over budget,
|
|
79
|
+
* iterate shows a priority-ordered subset and holds the rest (unmarked) for later ticks.
|
|
80
|
+
* `0` disables the cap.
|
|
81
|
+
*/
|
|
82
|
+
reviewOutputMaxChars: number;
|
|
77
83
|
/**
|
|
78
84
|
* When to resolve other-human inline threads after Shepherd replies. Own and bot threads
|
|
79
85
|
* always reply-and-resolve. `none` (default) keeps other humans reply-only; `outdated`
|
package/bin/config/load.mjs
CHANGED
|
@@ -92,6 +92,11 @@ function parseInstructionStyle(value) {
|
|
|
92
92
|
return value;
|
|
93
93
|
throw new Error(`Invalid config: iterate.instructions must be "playbook" or "inline", got ${JSON.stringify(value)}`);
|
|
94
94
|
}
|
|
95
|
+
function parseReviewOutputMaxChars(value) {
|
|
96
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0)
|
|
97
|
+
return value;
|
|
98
|
+
throw new Error(`Invalid config: iterate.reviewOutputMaxChars must be a non-negative integer, got ${JSON.stringify(value)}`);
|
|
99
|
+
}
|
|
95
100
|
function parseCliCommand(value) {
|
|
96
101
|
if (!Array.isArray(value) ||
|
|
97
102
|
value.length === 0 ||
|
|
@@ -299,6 +304,7 @@ const KNOWN_NESTED_KEYS = {
|
|
|
299
304
|
"behindBaseHint",
|
|
300
305
|
"resolveOtherHumanThreads",
|
|
301
306
|
"instructions",
|
|
307
|
+
"reviewOutputMaxChars",
|
|
302
308
|
]),
|
|
303
309
|
poll: new Set([
|
|
304
310
|
"intervalSeconds",
|
|
@@ -404,6 +410,7 @@ function parseConfig(value, normalizeMergeArgs = true) {
|
|
|
404
410
|
config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
|
|
405
411
|
config.iterate.resolveOtherHumanThreads = parseResolveOtherHumanThreads(config.iterate.resolveOtherHumanThreads);
|
|
406
412
|
config.iterate.instructions = parseInstructionStyle(config.iterate.instructions);
|
|
413
|
+
config.iterate.reviewOutputMaxChars = parseReviewOutputMaxChars(config.iterate.reviewOutputMaxChars);
|
|
407
414
|
config.checks.ignoreLogLines = parseIgnoreLogLines(config.checks.ignoreLogLines);
|
|
408
415
|
config.standardRules = parseStandardRules(config.classify);
|
|
409
416
|
return config;
|
package/bin/config.json
CHANGED
|
@@ -29,7 +29,11 @@ export function fingerprintRawSummaryPr(summary) {
|
|
|
29
29
|
// Omitted bodies cannot participate in the hash. Bind truncated evidence
|
|
30
30
|
// to the PR revision instead, so updates require a fresh full review poll.
|
|
31
31
|
...(reviewTruncated && { reviewUpdatedAt: raw.updatedAt }),
|
|
32
|
-
comments
|
|
32
|
+
// A PR comment edit is not readiness evidence; its ID still binds new comments.
|
|
33
|
+
comments: {
|
|
34
|
+
...raw.comments,
|
|
35
|
+
nodes: raw.comments.nodes.map(({ body: _body, ...node }) => node),
|
|
36
|
+
},
|
|
33
37
|
reviews: hideBodies(raw.reviews),
|
|
34
38
|
reviewThreads: {
|
|
35
39
|
...raw.reviewThreads,
|
|
@@ -19,7 +19,8 @@ export async function summarizePollSummaryReview(raw, seen, viewerCanAdminister)
|
|
|
19
19
|
raw.reviewThreads.pageInfo.hasPreviousPage;
|
|
20
20
|
for (const comment of raw.comments.nodes) {
|
|
21
21
|
if (!isSuppressed(rules, "pr-comment", comment, comment.id, undefined, comment.body, comment.isMinimized) &&
|
|
22
|
-
|
|
22
|
+
// An edited PR comment is shown once by iterate but never blocks readiness.
|
|
23
|
+
classifyItem(comment.id, comment.body, seen) === "new")
|
|
23
24
|
actionable += 1;
|
|
24
25
|
}
|
|
25
26
|
const latestStateByAuthor = new Map((raw.latestReviews?.nodes ?? []).flatMap((review) => review.author ? [[review.author.login.toLowerCase(), review.state]] : []));
|
|
@@ -10,9 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { ReviewThread, PrComment, TriagedCheck, ClassifiedCheck, AgentThread, AgentComment, AgentCheck, AgentStalledCheck } from "../types.mts";
|
|
12
12
|
export declare function toAgentThread(t: ReviewThread): AgentThread;
|
|
13
|
-
export declare function toAgentComment(c: PrComment
|
|
14
|
-
edited?: boolean;
|
|
15
|
-
}): AgentComment;
|
|
13
|
+
export declare function toAgentComment(c: PrComment): AgentComment;
|
|
16
14
|
export declare function toAgentCheck(c: TriagedCheck): AgentCheck;
|
|
17
15
|
export declare function toAgentStalledCheck(c: ClassifiedCheck, nowSeconds: number): AgentStalledCheck;
|
|
18
16
|
/**
|
package/bin/reporters/agent.mjs
CHANGED
package/bin/types/escalate.d.mts
CHANGED
|
@@ -31,6 +31,15 @@ export interface EscalateDetails {
|
|
|
31
31
|
threadId: string;
|
|
32
32
|
attempts: number;
|
|
33
33
|
}>;
|
|
34
|
+
/**
|
|
35
|
+
* Set only when `iterate.reviewOutputMaxChars` held review items back on the tick that
|
|
36
|
+
* escalated: `shown` of `total` review items appear here. The held items stay unmarked, so the
|
|
37
|
+
* next run shows them.
|
|
38
|
+
*/
|
|
39
|
+
reviewItems?: {
|
|
40
|
+
shown: number;
|
|
41
|
+
total: number;
|
|
42
|
+
};
|
|
34
43
|
/** Review mutations generated for this tick, retained so an escalation cannot strand them. */
|
|
35
44
|
pendingReviewCommands?: {
|
|
36
45
|
resolveOnlyCommand?: ResolveCommand;
|
package/bin/types/iterate.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentThread, AgentComment, AgentCheck, GlobalOptions, RelevantCheck, ShepherdStatus, FirstLookThread, FirstLookComment, RuleAutoResolveReport } from "./report.mts";
|
|
1
|
+
import type { AgentThread, AgentComment, AgentCheck, GlobalOptions, RelevantCheck, ShepherdStatus, FirstLookThread, FirstLookComment, EditedComment, RuleAutoResolveReport } from "./report.mts";
|
|
2
2
|
import type { ActiveCheck, PrActivitySummary } from "./activity.mts";
|
|
3
3
|
import type { BranchProtection, MergeStateStatus, MergedBasePullRequest, Review, ReviewDecision, ReviewThread, ShepherdMergeStatus } from "./github.mts";
|
|
4
4
|
import type { MergeRequirements } from "./merge-requirements.mts";
|
|
@@ -12,7 +12,10 @@ export interface IterateResultSummary {
|
|
|
12
12
|
passing: number;
|
|
13
13
|
skipped: number;
|
|
14
14
|
filtered: number;
|
|
15
|
+
/** Non-completed checks GitHub reports as `IN_PROGRESS`. */
|
|
15
16
|
inProgress: number;
|
|
17
|
+
/** Non-completed checks GitHub reports as `QUEUED`, `WAITING`, `PENDING`, or `REQUESTED`. */
|
|
18
|
+
queued: number;
|
|
16
19
|
superseded: number;
|
|
17
20
|
}
|
|
18
21
|
export interface IterateResultBase {
|
|
@@ -70,6 +73,11 @@ export interface IterateResultBase {
|
|
|
70
73
|
nextCheck?: NextCheck;
|
|
71
74
|
/** Classification-rule resolve/minimize applied during this fetch. Omitted when nothing happened. */
|
|
72
75
|
ruleAutoResolve?: RuleAutoResolveReport;
|
|
76
|
+
/**
|
|
77
|
+
* PR comments edited since Shepherd last showed them. Edits never block readiness, so they are
|
|
78
|
+
* held during `wait`/`ready` and shown once on the next other action. Omitted when empty.
|
|
79
|
+
*/
|
|
80
|
+
editedComments?: EditedComment[];
|
|
73
81
|
}
|
|
74
82
|
interface IterateResultWait extends IterateResultBase {
|
|
75
83
|
action: "wait";
|
|
@@ -143,6 +151,11 @@ interface FixRebaseAndPush {
|
|
|
143
151
|
firstLookThreads: FirstLookThread[];
|
|
144
152
|
/** First-look comments — previously hidden, surfaced for acknowledgment only. */
|
|
145
153
|
firstLookComments: FirstLookComment[];
|
|
154
|
+
/** Set only when `iterate.reviewOutputMaxChars` held review items back: `shown` of `total`. */
|
|
155
|
+
reviewItems?: {
|
|
156
|
+
shown: number;
|
|
157
|
+
total: number;
|
|
158
|
+
};
|
|
146
159
|
}
|
|
147
160
|
export interface IterateResultFixCode extends IterateResultBase {
|
|
148
161
|
action: "fix_code";
|
|
@@ -176,6 +189,11 @@ export interface IterateCommandOptions extends GlobalOptions {
|
|
|
176
189
|
* only by `runPollCore`; excluded from the public `IterateInput` in api.mts.
|
|
177
190
|
*/
|
|
178
191
|
persistSeen?: boolean;
|
|
192
|
+
/**
|
|
193
|
+
* Internal. `true` attaches edited comments without writing their markers; the poll loop
|
|
194
|
+
* writes them for the one result it returns. Set only by `runPollCore`.
|
|
195
|
+
*/
|
|
196
|
+
deferEditedCommentMarkers?: boolean;
|
|
179
197
|
/**
|
|
180
198
|
* Internal. `true` allows fingerprint reuse of a previous WAIT-shaped report.
|
|
181
199
|
* Poll sets this only for internal WAIT/MARK_READY continuation ticks — never the
|
package/bin/types/report.d.mts
CHANGED
|
@@ -26,10 +26,14 @@ export interface FirstLookThread extends ReviewThread {
|
|
|
26
26
|
}
|
|
27
27
|
export interface FirstLookComment extends PrComment {
|
|
28
28
|
firstLookStatus: "minimized";
|
|
29
|
-
edited?: boolean;
|
|
30
29
|
}
|
|
31
|
-
export type ActionableComment = PrComment
|
|
32
|
-
|
|
30
|
+
export type ActionableComment = PrComment;
|
|
31
|
+
/**
|
|
32
|
+
* A seen PR comment whose body changed since Shepherd last showed it. An edit is not readiness
|
|
33
|
+
* work: iterate shows it once on the next action that is not `wait` or `ready`.
|
|
34
|
+
*/
|
|
35
|
+
export type EditedComment = PrComment & {
|
|
36
|
+
edited: true;
|
|
33
37
|
};
|
|
34
38
|
export type ShepherdStatus = "MERGED" | "CLOSED" | "READY" | "FAILING" | "PENDING" | "IN_PROGRESS" | "UNRESOLVED_COMMENTS" | "UNKNOWN";
|
|
35
39
|
export interface ShepherdReport {
|
|
@@ -91,6 +95,8 @@ export interface ShepherdReport {
|
|
|
91
95
|
minimizeIds?: string[];
|
|
92
96
|
/** First-look items — minimized comments not yet seen by the agent. */
|
|
93
97
|
firstLook: FirstLookComment[];
|
|
98
|
+
/** Seen PR comments (minimized or not) whose body changed. Not marked seen by check. Omitted when empty. */
|
|
99
|
+
edited?: EditedComment[];
|
|
94
100
|
/** PR comments and review summaries minimized by a suppress+autoResolve rule this fetch. */
|
|
95
101
|
autoMinimized?: AutoMinimizedItem[];
|
|
96
102
|
};
|
|
@@ -103,6 +109,20 @@ export interface ShepherdReport {
|
|
|
103
109
|
editedSummaries: Review[];
|
|
104
110
|
/** APPROVED reviews not yet minimized — opt-in minimize target. */
|
|
105
111
|
approvedReviews: Review[];
|
|
112
|
+
/**
|
|
113
|
+
* Set only when `iterate.reviewOutputMaxChars` held review items back this tick: `shown` of
|
|
114
|
+
* `total` review items appear in this report. Held items are absent from every review list,
|
|
115
|
+
* from the generated mutation IDs, and from the seen markers written this tick.
|
|
116
|
+
*/
|
|
117
|
+
reviewItems?: {
|
|
118
|
+
shown: number;
|
|
119
|
+
total: number;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Internal, iterate-only: parent review IDs of unresolved threads held by the budget. Their
|
|
123
|
+
* summaries are not minimized this tick. Not rendered.
|
|
124
|
+
*/
|
|
125
|
+
heldThreadReviewIds?: string[];
|
|
106
126
|
/** COMMENTED review summary IDs matched by user rules with autoResolve:true — minimized without surfacing to agent. */
|
|
107
127
|
ruleAutoResolveReviewSummaryIds?: string[];
|
|
108
128
|
/** Branch protection rule for the PR's base branch. Null when no rule exists or the base ref is unavailable. */
|
|
@@ -167,7 +187,6 @@ export interface AgentComment {
|
|
|
167
187
|
authorAssociation?: import("./github.mts").CommentAuthorAssociation;
|
|
168
188
|
body: string;
|
|
169
189
|
url: string;
|
|
170
|
-
edited?: boolean;
|
|
171
190
|
}
|
|
172
191
|
/** Check shape emitted to the iterate agent under `fix_code` or check escalation. */
|
|
173
192
|
export interface AgentCheck {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** First `LEAN_LIST_CAP` items plus the count of items left out. */
|
|
2
|
+
export declare function capList<T>(items: readonly T[]): {
|
|
3
|
+
shown: T[];
|
|
4
|
+
hidden: number;
|
|
5
|
+
};
|
|
6
|
+
/** `", +N more"` suffix for a capped inline list; empty when nothing was left out. */
|
|
7
|
+
export declare function moreSuffix(hidden: number): string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Lean list cap shared by Markdown lines, lean JSON, and wait logs. */
|
|
2
|
+
const LEAN_LIST_CAP = 5;
|
|
3
|
+
/** First `LEAN_LIST_CAP` items plus the count of items left out. */
|
|
4
|
+
export function capList(items) {
|
|
5
|
+
return {
|
|
6
|
+
shown: items.slice(0, LEAN_LIST_CAP),
|
|
7
|
+
hidden: Math.max(0, items.length - LEAN_LIST_CAP),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** `", +N more"` suffix for a capped inline list; empty when nothing was left out. */
|
|
11
|
+
export function moreSuffix(hidden) {
|
|
12
|
+
return hidden > 0 ? `, +${hidden} more` : "";
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.1",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"automation",
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"husky": "^9.1.7",
|
|
91
91
|
"knip": "^6.14.1",
|
|
92
92
|
"marked": "^18.0.11",
|
|
93
|
-
"oxfmt": "^0.
|
|
93
|
+
"oxfmt": "^0.72.0",
|
|
94
94
|
"oxlint": "^1.60.0",
|
|
95
95
|
"typescript": "^7.0.2",
|
|
96
96
|
"vitest": "^5.0.0"
|