pr-shepherd 0.49.0 → 0.51.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/README.md +13 -6
- package/bin/cli/help-command-pages.d.mts +8 -8
- package/bin/cli/help-command-pages.mjs +8 -8
- package/bin/cli/help.d.mts +8 -8
- package/bin/cli/iterate-instructions.mjs +9 -2
- package/bin/cli/iterate-lean.mjs +11 -0
- package/bin/cli/poll-summary-emitter.mjs +15 -0
- package/bin/cli/poll-summary-formatter.mjs +10 -24
- package/bin/cli-parser.mjs +3 -0
- package/bin/commands/iterate/check-instructions.d.mts +2 -8
- package/bin/commands/iterate/check-instructions.mjs +2 -11
- package/bin/commands/iterate/escalate.mjs +30 -2
- package/bin/commands/iterate/fix-code.mjs +62 -20
- package/bin/commands/iterate/render.mjs +1 -1
- package/bin/commands/iterate/stall.mjs +30 -0
- package/bin/commands/iterate/thread-mutation-routing.d.mts +0 -2
- package/bin/commands/iterate/thread-mutation-routing.mjs +1 -2
- package/bin/commands/poll-quota.d.mts +2 -1
- package/bin/commands/poll-quota.mjs +12 -0
- package/bin/commands/poll-summary-explicit-instructions.d.mts +2 -0
- package/bin/commands/poll-summary-explicit-instructions.mjs +22 -0
- package/bin/commands/poll-summary-instructions.d.mts +3 -0
- package/bin/commands/poll-summary-instructions.mjs +139 -0
- package/bin/commands/poll-summary-signature.d.mts +2 -0
- package/bin/commands/poll-summary-signature.mjs +16 -0
- package/bin/commands/poll-summary.mjs +25 -39
- package/bin/commands/resolve-mutate.mjs +22 -68
- package/bin/comments/resolve.d.mts +5 -0
- package/bin/comments/resolve.mjs +2 -11
- package/bin/github/gql/poll-summary-fragment.gql +1 -0
- package/bin/github/poll-summary-projector.mjs +2 -1
- package/bin/github/poll-summary-raw.d.mts +1 -0
- package/bin/github/poll-summary-route.mjs +4 -1
- package/bin/github/poll-summary.d.mts +2 -1
- package/bin/github/poll-summary.mjs +19 -0
- package/bin/state/fix-attempts.d.mts +3 -4
- package/bin/state/fix-attempts.mjs +2 -3
- package/bin/types/escalate.d.mts +10 -0
- package/bin/types/poll-summary.d.mts +14 -0
- package/package.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
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +6 -11
- package/plugins/pr-shepherd/skills/reduce-pr-noise/SKILL.md +17 -0
- package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +24 -0
- package/plugins/pr-shepherd/skills/reduce-pr-noise/references/settings.md +27 -0
|
@@ -33,7 +33,7 @@ function checkRequiresHumanFollowUp(check) {
|
|
|
33
33
|
return !check.detailsUrl?.trim();
|
|
34
34
|
return !check.logExcerpt?.trim();
|
|
35
35
|
}
|
|
36
|
-
function nextFixAttempts(stored,
|
|
36
|
+
function nextFixAttempts(stored, threads, countAttempt) {
|
|
37
37
|
const threadAttempts = stored ? { ...stored.threadAttempts } : {};
|
|
38
38
|
const threadBodyHashes = stored?.threadBodyHashes
|
|
39
39
|
? { ...stored.threadBodyHashes }
|
|
@@ -41,13 +41,32 @@ function nextFixAttempts(stored, headSha, threads) {
|
|
|
41
41
|
for (const t of threads) {
|
|
42
42
|
const bodyHash = hashBody(threadTranscriptBody(t));
|
|
43
43
|
const previousHash = threadBodyHashes[t.id];
|
|
44
|
-
if (
|
|
44
|
+
if (!countAttempt)
|
|
45
45
|
continue;
|
|
46
46
|
threadAttempts[t.id] = previousHash === bodyHash ? (threadAttempts[t.id] ?? 0) + 1 : 1;
|
|
47
47
|
threadBodyHashes[t.id] = bodyHash;
|
|
48
48
|
}
|
|
49
49
|
return { threadAttempts, threadBodyHashes };
|
|
50
50
|
}
|
|
51
|
+
function previousFixAttempts(stored, threads) {
|
|
52
|
+
if (!stored?.threadBodyHashes)
|
|
53
|
+
return {};
|
|
54
|
+
const attempts = {};
|
|
55
|
+
for (const thread of threads) {
|
|
56
|
+
const bodyHash = hashBody(threadTranscriptBody(thread));
|
|
57
|
+
if (stored.threadBodyHashes[thread.id] === bodyHash) {
|
|
58
|
+
attempts[thread.id] = stored.threadAttempts[thread.id] ?? 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return attempts;
|
|
62
|
+
}
|
|
63
|
+
function pendingReviewCommands(resolveCommand, resolveOnlyCommand) {
|
|
64
|
+
const pending = {
|
|
65
|
+
...(resolveOnlyCommand?.hasMutations && { resolveOnlyCommand }),
|
|
66
|
+
...(resolveCommand.hasMutations && { resolveCommand }),
|
|
67
|
+
};
|
|
68
|
+
return Object.keys(pending).length > 0 ? pending : undefined;
|
|
69
|
+
}
|
|
51
70
|
export async function handleFixCode(ctx) {
|
|
52
71
|
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, } = ctx;
|
|
53
72
|
const prReference = formatPrUrl(report.repo, prNumber);
|
|
@@ -70,7 +89,22 @@ export async function handleFixCode(ctx) {
|
|
|
70
89
|
const retryableActionableThreads = mutationActionableThreads.filter((thread) => thread.path !== null && thread.line !== null);
|
|
71
90
|
const protectedRuns = [];
|
|
72
91
|
const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
|
|
73
|
-
const
|
|
92
|
+
const countFixCodeAttempt = opts.persistSeen !== false;
|
|
93
|
+
const priorThreadAttempts = previousFixAttempts(stored, retryableActionableThreads);
|
|
94
|
+
const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, retryableActionableThreads, countFixCodeAttempt);
|
|
95
|
+
const resolutionOnlyThreadsForWork = report.threads.resolutionOnly.filter((thread) => !skippedThreadIds.has(thread.id) &&
|
|
96
|
+
((thread.path !== null && thread.line !== null) ||
|
|
97
|
+
threadHasAuthorizedMutation(thread, replyIdSet, resolveIdSet)));
|
|
98
|
+
const actionableChangesRequestedReviews = report.changesRequestedReviews.filter((review) => review.staleReview !== true ||
|
|
99
|
+
!isHumanAuthor(review) ||
|
|
100
|
+
isConfiguredBotAuthor(review, botUsernames));
|
|
101
|
+
const changesRequestedReviewsForWork = actionableChangesRequestedReviews.filter((review) => !unauthorizedDismissals.some((candidate) => candidate.id === review.id));
|
|
102
|
+
const buildReviewCommands = (checks) => buildResolveCommand(report.threads.actionable
|
|
103
|
+
.filter((thread) => mutationActionableThreads.some((candidate) => candidate.id === thread.id))
|
|
104
|
+
.map(toAgentThread), resolutionOnlyThreadsForWork, [
|
|
105
|
+
...(report.comments.minimizeIds ?? report.comments.actionable.map((comment) => comment.id)),
|
|
106
|
+
...reviewSummaryIds,
|
|
107
|
+
], changesRequestedReviewsForWork, checks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads, resolveOtherHumanThreads);
|
|
74
108
|
const botCrReviews = report.changesRequestedReviews.filter((r) => (!isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames)) &&
|
|
75
109
|
report.viewerAuthorization?.viewerCanAdminister === true);
|
|
76
110
|
const botCrStateKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
@@ -79,13 +113,16 @@ export async function handleFixCode(ctx) {
|
|
|
79
113
|
const { next: nextBotCrState, staleIds: staleBotCrIds } = updateBotCrSeenState(previousBotCrState, botCrReviews, nowSeconds, stallTimeoutSeconds);
|
|
80
114
|
await writeBotCrSeenState(botCrStateKey, nextBotCrState);
|
|
81
115
|
if (staleBotCrIds.length > 0) {
|
|
82
|
-
const
|
|
83
|
-
const
|
|
116
|
+
const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(toAgentChecks(failingChecks));
|
|
117
|
+
const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
|
|
84
118
|
const escalateBase = {
|
|
85
119
|
triggers: ["bot-cr-not-dismissed"],
|
|
86
120
|
unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
|
|
87
121
|
ambiguousComments: report.comments.actionable.map(toAgentComment),
|
|
88
|
-
changesRequestedReviews:
|
|
122
|
+
changesRequestedReviews: report.changesRequestedReviews,
|
|
123
|
+
...(firstLookSummaries.length > 0 && { firstLookSummaries }),
|
|
124
|
+
...(editedSummaries.length > 0 && { editedSummaries }),
|
|
125
|
+
...(pending && { pendingReviewCommands: pending }),
|
|
89
126
|
suggestion: buildEscalateSuggestion(["bot-cr-not-dismissed"], staleBotCrIds.join(", ")),
|
|
90
127
|
};
|
|
91
128
|
return {
|
|
@@ -99,14 +136,21 @@ export async function handleFixCode(ctx) {
|
|
|
99
136
|
},
|
|
100
137
|
};
|
|
101
138
|
}
|
|
102
|
-
const escalateTriggers =
|
|
139
|
+
const escalateTriggers = countFixCodeAttempt
|
|
140
|
+
? checkEscalateTriggers(retryableActionableThreads, priorThreadAttempts)
|
|
141
|
+
: { triggers: [], thrashHistory: undefined };
|
|
103
142
|
if (escalateTriggers.triggers.length > 0) {
|
|
143
|
+
const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(toAgentChecks(failingChecks));
|
|
144
|
+
const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
|
|
104
145
|
const escalateBase = {
|
|
105
146
|
triggers: escalateTriggers.triggers,
|
|
106
147
|
unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
|
|
107
148
|
ambiguousComments: report.comments.actionable.map(toAgentComment),
|
|
108
149
|
changesRequestedReviews: report.changesRequestedReviews,
|
|
150
|
+
...(firstLookSummaries.length > 0 && { firstLookSummaries }),
|
|
151
|
+
...(editedSummaries.length > 0 && { editedSummaries }),
|
|
109
152
|
thrashHistory: escalateTriggers.thrashHistory,
|
|
153
|
+
...(pending && { pendingReviewCommands: pending }),
|
|
110
154
|
suggestion: buildEscalateSuggestion(escalateTriggers.triggers),
|
|
111
155
|
};
|
|
112
156
|
return {
|
|
@@ -120,7 +164,6 @@ export async function handleFixCode(ctx) {
|
|
|
120
164
|
},
|
|
121
165
|
};
|
|
122
166
|
}
|
|
123
|
-
await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts, threadBodyHashes });
|
|
124
167
|
// GitHub does not expose a per-run viewer capability for cancellation, so Shepherd never
|
|
125
168
|
// issues or recommends a cancellation regardless of repository role. A rerun is different:
|
|
126
169
|
// GitHub's Actions rerun API requires actions:write, which rides with WRITE+ repo access, so
|
|
@@ -163,14 +206,6 @@ export async function handleFixCode(ctx) {
|
|
|
163
206
|
...toAgentChecks(annotatedExtra).map((c) => ({ ...c, annotationOnly: true })),
|
|
164
207
|
];
|
|
165
208
|
const { changesRequestedReviews } = report;
|
|
166
|
-
const actionableChangesRequestedReviews = changesRequestedReviews.filter((review) => review.staleReview !== true ||
|
|
167
|
-
!isHumanAuthor(review) ||
|
|
168
|
-
isConfiguredBotAuthor(review, botUsernames));
|
|
169
|
-
const skippedDismissalIds = new Set(unauthorizedDismissals.map((review) => review.id));
|
|
170
|
-
const changesRequestedReviewsForWork = actionableChangesRequestedReviews.filter((review) => !skippedDismissalIds.has(review.id));
|
|
171
|
-
const resolutionOnlyThreadsForWork = resolutionOnlyThreads.filter((thread) => !skippedThreadIds.has(thread.id) &&
|
|
172
|
-
((thread.path !== null && thread.line !== null) ||
|
|
173
|
-
threadHasAuthorizedMutation(thread, replyIdSet, resolveIdSet)));
|
|
174
209
|
const hasConflicts = report.mergeStatus.status === "CONFLICTS";
|
|
175
210
|
const isBehind = report.mergeStatus.status === "BEHIND";
|
|
176
211
|
const { behindBaseHint } = loadConfig().iterate;
|
|
@@ -179,7 +214,6 @@ export async function handleFixCode(ctx) {
|
|
|
179
214
|
// unnecessary cancellation.
|
|
180
215
|
const inProgressRunIds = [];
|
|
181
216
|
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
182
|
-
const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
|
|
183
217
|
const belongsToActiveWorkflowRun = (check) => check.runId !== null && inProgressWorkflowRunIds.has(check.runId);
|
|
184
218
|
const manualFollowUpChecks = failingAgentChecks.filter((check) => !belongsToActiveWorkflowRun(check) && checkRequiresHumanFollowUp(check));
|
|
185
219
|
const exhaustedAttempts = manualFollowUpChecks.filter((check) => check.runAttempt !== undefined && check.runAttempt > 1);
|
|
@@ -199,6 +233,8 @@ export async function handleFixCode(ctx) {
|
|
|
199
233
|
checks.some((check) => (check.annotations?.length ?? 0) > 0) ||
|
|
200
234
|
failingAgentChecks.some((check) => belongsToActiveWorkflowRun(check) || !checkRequiresHumanFollowUp(check));
|
|
201
235
|
if (manualFollowUpChecks.length > 0 && !hasAutonomousWork) {
|
|
236
|
+
const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(failingAgentChecks);
|
|
237
|
+
const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
|
|
202
238
|
const checkSuggestion = exhaustedAttempts.length > 0
|
|
203
239
|
? `GitHub reports a later workflow attempt (${exhaustedAttempts
|
|
204
240
|
.map((check) => `${check.runId ?? check.name}: attempt ${check.runAttempt}`)
|
|
@@ -209,7 +245,10 @@ export async function handleFixCode(ctx) {
|
|
|
209
245
|
unresolvedThreads: [],
|
|
210
246
|
ambiguousComments: [],
|
|
211
247
|
changesRequestedReviews,
|
|
248
|
+
...(firstLookSummaries.length > 0 && { firstLookSummaries }),
|
|
249
|
+
...(editedSummaries.length > 0 && { editedSummaries }),
|
|
212
250
|
checks: manualFollowUpChecks,
|
|
251
|
+
...(pending && { pendingReviewCommands: pending }),
|
|
213
252
|
suggestion: checkSuggestion,
|
|
214
253
|
};
|
|
215
254
|
return {
|
|
@@ -225,9 +264,7 @@ export async function handleFixCode(ctx) {
|
|
|
225
264
|
}
|
|
226
265
|
// Push access to the PR head branch is a usage precondition. Build review mutations for
|
|
227
266
|
// conflict ticks normally so the caller can push and complete the same fix_code cycle.
|
|
228
|
-
const
|
|
229
|
-
const mutationAgentThreads = threads.filter((thread) => mutationActionableIds.has(thread.id));
|
|
230
|
-
const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(mutationAgentThreads, resolutionOnlyThreadsForWork, allCommentIds, changesRequestedReviewsForWork, failingAgentChecks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads, resolveOtherHumanThreads);
|
|
267
|
+
const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(failingAgentChecks);
|
|
231
268
|
// Safety: if the base branch is unknown, escalate when a push is plausible — the agent
|
|
232
269
|
// would need the correct base to rebase safely. This is a conservative guard, not a
|
|
233
270
|
// prediction that the agent *will* push. Located resolution-only threads retain that guard;
|
|
@@ -241,11 +278,15 @@ export async function handleFixCode(ctx) {
|
|
|
241
278
|
actionableComments.length > 0 ||
|
|
242
279
|
locatedResolutionOnlyThreadsForWork.length > 0;
|
|
243
280
|
if (baseLookup.isFallback && pushIsPlausible) {
|
|
281
|
+
const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
|
|
244
282
|
const fallbackEscalateBase = {
|
|
245
283
|
triggers: ["base-branch-unknown"],
|
|
246
284
|
unresolvedThreads: [...threads, ...resolutionOnlyThreads.map(toAgentThread)],
|
|
247
285
|
ambiguousComments: actionableComments,
|
|
248
286
|
changesRequestedReviews,
|
|
287
|
+
...(firstLookSummaries.length > 0 && { firstLookSummaries }),
|
|
288
|
+
...(editedSummaries.length > 0 && { editedSummaries }),
|
|
289
|
+
...(pending && { pendingReviewCommands: pending }),
|
|
249
290
|
suggestion: buildEscalateSuggestion(["base-branch-unknown"], baseLookup.failureReason),
|
|
250
291
|
};
|
|
251
292
|
return {
|
|
@@ -288,6 +329,7 @@ export async function handleFixCode(ctx) {
|
|
|
288
329
|
};
|
|
289
330
|
const result = await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds);
|
|
290
331
|
if (result.action === "fix_code" && opts.persistSeen !== false) {
|
|
332
|
+
await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts, threadBodyHashes });
|
|
291
333
|
await Promise.allSettled(result.fix.checks.flatMap((ch) => (ch.annotations ?? []).map((a) => markSeen(stallKey, a.id, annotationMarkerBody(a)))));
|
|
292
334
|
}
|
|
293
335
|
return result;
|
|
@@ -69,7 +69,7 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
|
|
|
69
69
|
instructions.push(`Apply every warranted review fix in ${filesRef}.`);
|
|
70
70
|
}
|
|
71
71
|
if (resolutionOnlyThreads.length > 0) {
|
|
72
|
-
instructions.push(
|
|
72
|
+
instructions.push("Review the threads under `## Review threads to resolve` before running the generated mutations.");
|
|
73
73
|
}
|
|
74
74
|
instructions.push(...buildFailingCheckInstructions(failingChecks), ...repeatedWorkflowBranchRecoveryInstructions);
|
|
75
75
|
if (hasAnnotations) {
|
|
@@ -1,8 +1,32 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
|
|
2
3
|
import { toAgentThread, toAgentComment, toAgentStalledCheck } from "../../reporters/agent.mjs";
|
|
3
4
|
import { buildEscalateSuggestion, buildEscalateHumanMessage, formatDurationApprox, } from "./escalate.mjs";
|
|
4
5
|
import { checksWithActionableAnnotations } from "../check-annotations.mjs";
|
|
5
6
|
import { formatPrUrl } from "../../pr-reference.mjs";
|
|
7
|
+
function pendingReviewCommandsFromResult(result) {
|
|
8
|
+
if (result.action !== "fix_code")
|
|
9
|
+
return undefined;
|
|
10
|
+
const pending = {
|
|
11
|
+
...(result.fix.resolveOnlyCommand?.hasMutations && {
|
|
12
|
+
resolveOnlyCommand: result.fix.resolveOnlyCommand,
|
|
13
|
+
}),
|
|
14
|
+
...(result.fix.resolveCommand.hasMutations && { resolveCommand: result.fix.resolveCommand }),
|
|
15
|
+
};
|
|
16
|
+
return Object.keys(pending).length > 0 ? pending : undefined;
|
|
17
|
+
}
|
|
18
|
+
function surfacedSummariesFromResult(result) {
|
|
19
|
+
if (result.action !== "fix_code")
|
|
20
|
+
return {};
|
|
21
|
+
return {
|
|
22
|
+
...(result.fix.firstLookSummaries.length > 0 && {
|
|
23
|
+
firstLookSummaries: result.fix.firstLookSummaries,
|
|
24
|
+
}),
|
|
25
|
+
...(result.fix.editedSummaries.length > 0 && {
|
|
26
|
+
editedSummaries: result.fix.editedSummaries,
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
6
30
|
function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
|
|
7
31
|
const checks = [
|
|
8
32
|
...report.checks.failing.map((f) => `failing:${f.name}:${f.conclusion}:${f.runId ?? "no-run"}:${f.runAttempt ?? "unknown"}`),
|
|
@@ -44,13 +68,16 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
|
|
|
44
68
|
action: prospectiveResult.action,
|
|
45
69
|
});
|
|
46
70
|
if (stalledChecks.length > 0) {
|
|
71
|
+
const pending = pendingReviewCommandsFromResult(prospectiveResult);
|
|
47
72
|
const stalledDuration = formatDurationApprox(Math.max(...stalledChecks.map((c) => c.ageSeconds)));
|
|
48
73
|
const escalateBase = {
|
|
49
74
|
triggers: ["stall-timeout"],
|
|
50
75
|
unresolvedThreads: [],
|
|
51
76
|
ambiguousComments: [],
|
|
52
77
|
changesRequestedReviews: [],
|
|
78
|
+
...surfacedSummariesFromResult(prospectiveResult),
|
|
53
79
|
stalledChecks,
|
|
80
|
+
...(pending && { pendingReviewCommands: pending }),
|
|
54
81
|
suggestion: buildEscalateSuggestion(["stall-timeout"], stalledDuration),
|
|
55
82
|
};
|
|
56
83
|
return {
|
|
@@ -76,11 +103,14 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
|
|
|
76
103
|
}
|
|
77
104
|
else if (ageSeconds >= stallTimeoutSeconds) {
|
|
78
105
|
const stalledDuration = formatDurationApprox(ageSeconds);
|
|
106
|
+
const pending = pendingReviewCommandsFromResult(prospectiveResult);
|
|
79
107
|
const escalateBase = {
|
|
80
108
|
triggers: ["stall-timeout"],
|
|
81
109
|
unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
|
|
82
110
|
ambiguousComments: report.comments.actionable.map(toAgentComment),
|
|
83
111
|
changesRequestedReviews: report.changesRequestedReviews,
|
|
112
|
+
...surfacedSummariesFromResult(prospectiveResult),
|
|
113
|
+
...(pending && { pendingReviewCommands: pending }),
|
|
84
114
|
suggestion: buildEscalateSuggestion(["stall-timeout"], stalledDuration),
|
|
85
115
|
};
|
|
86
116
|
return {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { type NormalizedBotUsernames } from "../../comments/authors.mts";
|
|
2
|
-
import { shouldResolveOtherHumanThread } from "../../comments/thread-resolve-policy.mts";
|
|
3
2
|
import type { ResolveOtherHumanThreads } from "../../config/load.mts";
|
|
4
3
|
import type { AgentThread, ReviewThread } from "../../types.mts";
|
|
5
|
-
export { shouldResolveOtherHumanThread };
|
|
6
4
|
export type RoutableThread = AgentThread | ReviewThread;
|
|
7
5
|
export interface ThreadMutationRouting {
|
|
8
6
|
replyThreadIds: string[];
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, } from "../../comments/authors.mjs";
|
|
2
2
|
import { threadEndedByShepherd } from "../../comments/marker.mjs";
|
|
3
3
|
import { shouldResolveOtherHumanThread } from "../../comments/thread-resolve-policy.mjs";
|
|
4
|
-
export { shouldResolveOtherHumanThread };
|
|
5
4
|
function dedupeIds(ids) {
|
|
6
5
|
return [...new Set(ids)];
|
|
7
6
|
}
|
|
@@ -32,7 +31,7 @@ export function buildThreadMutationRouting(threads, botUsernames, ruleAutoResolv
|
|
|
32
31
|
.filter((thread) => shouldPairResolve(thread, botUsernames, policy) && !threadEndedByShepherd(thread))
|
|
33
32
|
.map((thread) => thread.id));
|
|
34
33
|
const pairedResolveIdSet = new Set(pairedResolveThreadIds);
|
|
35
|
-
// Rule-matched threads bypass
|
|
34
|
+
// Rule-matched threads bypass the generated-command author routing.
|
|
36
35
|
const standaloneResolveThreadIds = dedupeIds([
|
|
37
36
|
...threads
|
|
38
37
|
.filter((thread) => shouldPairResolve(thread, botUsernames, policy) && threadEndedByShepherd(thread))
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GraphqlQuotaWarningBand } from "../config/load.mts";
|
|
2
|
-
import type { GraphqlApiUsage } from "../types.mts";
|
|
2
|
+
import type { GraphqlApiUsage, PollSummaryResult } from "../types.mts";
|
|
3
3
|
/** Sleep at least `--interval`, and at least the active crossed quota band. */
|
|
4
4
|
export declare function graphqlQuotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], usage: Pick<GraphqlApiUsage, "remaining" | "limit"> | undefined, fallbackMs: number, maxMs: number): number;
|
|
5
5
|
/**
|
|
@@ -7,3 +7,4 @@ export declare function graphqlQuotaPollIntervalMs(bands: GraphqlQuotaWarningBan
|
|
|
7
7
|
* limit. `null` means the error is not a retryable rate limit.
|
|
8
8
|
*/
|
|
9
9
|
export declare function pollGraphQlRetryAfterMs(err: unknown): number | null;
|
|
10
|
+
export declare function aggregateQuotaWarning(result: PollSummaryResult, bands: GraphqlQuotaWarningBand[], intervalSeconds: number): Promise<PollSummaryResult["quotaWarning"]>;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { evaluateWorktreeGraphqlQuotaWarning } from "../state/graphql-quota-warnings.mjs";
|
|
2
|
+
import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
|
|
1
3
|
import { GitHubRequestError } from "../github/errors.mjs";
|
|
2
4
|
import { isRateLimitMessage } from "../comments/rate-limit.mjs";
|
|
3
5
|
const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
|
|
@@ -34,3 +36,13 @@ export function pollGraphQlRetryAfterMs(err) {
|
|
|
34
36
|
}
|
|
35
37
|
return GRAPHQL_RETRY_AFTER_DEFAULT_MS;
|
|
36
38
|
}
|
|
39
|
+
export async function aggregateQuotaWarning(result, bands, intervalSeconds) {
|
|
40
|
+
const usage = summarizeApiTelemetry()?.graphql;
|
|
41
|
+
const [owner, repo] = result.repo.split("/");
|
|
42
|
+
if (!usage || !owner || !repo)
|
|
43
|
+
return undefined;
|
|
44
|
+
return evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands.map((band) => ({
|
|
45
|
+
...band,
|
|
46
|
+
pollIntervalMinutes: Math.max(band.pollIntervalMinutes, intervalSeconds / 60),
|
|
47
|
+
})), usage, true);
|
|
48
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
|
|
2
|
+
export function explicitInstructions(result) {
|
|
3
|
+
if (result.reason === "all_terminal")
|
|
4
|
+
return ["1. Stop — every selected PR is terminal."];
|
|
5
|
+
if (result.quotaWarning && result.reason !== "actionable") {
|
|
6
|
+
return [
|
|
7
|
+
buildQuotaAwareContinuation(result.quotaWarning, "1. This aggregate selection is non-terminal. Before continuing,"),
|
|
8
|
+
];
|
|
9
|
+
}
|
|
10
|
+
if (result.reason === "waiting" || result.reason === "timeout") {
|
|
11
|
+
return ["1. Run this aggregate selector again when the caller is ready to recheck."];
|
|
12
|
+
}
|
|
13
|
+
const instructions = [
|
|
14
|
+
"1. Choose each non-WAIT, non-CANCEL row that can proceed independently and run or delegate its exact `pollCommand`.",
|
|
15
|
+
"2. Follow each selected one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
|
|
16
|
+
"3. Run this aggregate poll again after selected work completes; one row's `ESCALATE` does not stop work on other rows.",
|
|
17
|
+
];
|
|
18
|
+
if (result.quotaWarning) {
|
|
19
|
+
instructions[2] = buildQuotaAwareContinuation(result.quotaWarning, "3. After selected work completes,");
|
|
20
|
+
}
|
|
21
|
+
return instructions;
|
|
22
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
|
|
2
|
+
import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
|
|
3
|
+
/** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
|
|
4
|
+
export function withPollSummaryInstructions(result, mergeRequested) {
|
|
5
|
+
if (result.selection.kind !== "stack") {
|
|
6
|
+
return { ...result, instructions: explicitInstructions(result) };
|
|
7
|
+
}
|
|
8
|
+
const planned = planStack(result, mergeRequested);
|
|
9
|
+
const reason = planned.action === "cancel"
|
|
10
|
+
? "all_terminal"
|
|
11
|
+
: planned.action === "wait"
|
|
12
|
+
? result.reason === "timeout"
|
|
13
|
+
? "timeout"
|
|
14
|
+
: "waiting"
|
|
15
|
+
: "actionable";
|
|
16
|
+
const instructions = [...planned.instructions];
|
|
17
|
+
if (result.quotaWarning && planned.action !== "wait" && planned.action !== "cancel") {
|
|
18
|
+
instructions.push(buildQuotaAwareContinuation(result.quotaWarning, `${instructions.length + 1}. After completing the stack action,`));
|
|
19
|
+
}
|
|
20
|
+
return { ...result, reason, nextAction: planned.action, instructions };
|
|
21
|
+
}
|
|
22
|
+
function planStack(result, mergeRequested) {
|
|
23
|
+
const open = result.prs.filter((item) => item.state === "OPEN");
|
|
24
|
+
if (open.length === 0) {
|
|
25
|
+
return { action: "cancel", instructions: ["1. Stop — every selected PR is terminal."] };
|
|
26
|
+
}
|
|
27
|
+
const lastOpenPosition = positionOf(result, open.at(-1).pr);
|
|
28
|
+
const closedBelowOpen = result.prs.find((item) => item.state === "CLOSED" && positionOf(result, item.pr) < lastOpenPosition);
|
|
29
|
+
if (closedBelowOpen) {
|
|
30
|
+
return {
|
|
31
|
+
action: "escalate",
|
|
32
|
+
instructions: [
|
|
33
|
+
`1. PR #${closedBelowOpen.pr} is closed without merging below an open stack layer. Stop stack merge and rebase operations here; the closed dependency must be restored or the higher branches rebuilt on a valid base.`,
|
|
34
|
+
"2. Ask the stack owner which recovery path to take, then rerun the same aggregate `--stack` selector after the stack is repaired.",
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const gap = result.stackAncestry?.[0];
|
|
39
|
+
const firstOpen = open[0];
|
|
40
|
+
if (firstOpen.mergeStateStatus === "BEHIND")
|
|
41
|
+
return rebaseWholeStack(result, firstOpen);
|
|
42
|
+
if (mergeRequested) {
|
|
43
|
+
const mergeTarget = readyLowerStackTarget(open, result.stackAncestry ?? []);
|
|
44
|
+
if (mergeTarget) {
|
|
45
|
+
const stackNumber = result.selection.kind === "stack" ? result.selection.stackNumber : 0;
|
|
46
|
+
return {
|
|
47
|
+
action: "merge",
|
|
48
|
+
instructions: [
|
|
49
|
+
`1. The contiguous ready lower stack ends at PR #${mergeTarget.pr}. Merge the native stack through that PR with \`gh stack merge --squash ${mergeTarget.pr}\`; verify that the selector names PR #${mergeTarget.pr} in stack #${stackNumber} before running it. This includes still-open lower layers and leaves higher layers open.`,
|
|
50
|
+
"2. After GitHub completes the stack merge and updates the remaining branches, rerun the same aggregate `--stack` selector. If an ancestry mismatch remains, follow the rebase instructions returned then.",
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (firstOpen.action === "wait") {
|
|
55
|
+
return waitingStack(result);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const firstWork = open.find((item) => ["fix_code", "mark_ready", "escalate"].includes(item.action));
|
|
59
|
+
if (firstWork && (!gap || positionOf(result, firstWork.pr) <= positionOf(result, gap.childPr))) {
|
|
60
|
+
return pollOneLayer(firstWork);
|
|
61
|
+
}
|
|
62
|
+
if (gap) {
|
|
63
|
+
return {
|
|
64
|
+
action: "fix_code",
|
|
65
|
+
instructions: [
|
|
66
|
+
`1. PR #${gap.childPr} still records base \`${gap.childBaseRefName}\` at \`${gap.childBaseRefOid}\`, while parent PR #${gap.parentPr} now ends at \`${gap.parentHeadRefName}\` \`${gap.parentHeadRefOid}\`. From a clean checkout of \`${result.repo}\`, check out the parent stack branch \`${gap.parentHeadRefName}\`.`,
|
|
67
|
+
"2. Rebase the upstack branches onto that parent with `gh stack rebase --upstack --no-trunk`, resolve any conflicts, and push the rewritten branches with `gh stack push`.",
|
|
68
|
+
"3. Rerun the same aggregate `--stack` selector and follow the next returned action.",
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const behind = open.find((item) => item.mergeStateStatus === "BEHIND");
|
|
73
|
+
if (behind)
|
|
74
|
+
return rebaseWholeStack(result, behind);
|
|
75
|
+
if (firstWork)
|
|
76
|
+
return pollOneLayer(firstWork);
|
|
77
|
+
if (!mergeRequested && open.every((item) => item.action === "cancel")) {
|
|
78
|
+
return {
|
|
79
|
+
action: "cancel",
|
|
80
|
+
instructions: ["1. Stop — every open stack layer is ready and the stack is linear."],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return waitingStack(result);
|
|
84
|
+
}
|
|
85
|
+
function rebaseWholeStack(result, behind) {
|
|
86
|
+
return {
|
|
87
|
+
action: "fix_code",
|
|
88
|
+
instructions: [
|
|
89
|
+
`1. GitHub reports PR #${behind.pr} is behind its base \`${behind.baseRefName}\`. From a clean checkout of \`${result.repo}\`, check out its stack branch \`${behind.headRefName}\`.`,
|
|
90
|
+
"2. Rebase that native stack from its trunk with `gh stack rebase`, resolving any conflicts.",
|
|
91
|
+
"3. Push the updated stack with `gh stack push` and rerun the same aggregate `--stack` selector.",
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function readyLowerStackTarget(open, gaps) {
|
|
96
|
+
const mismatchedChildren = new Set(gaps.map((gap) => gap.childPr));
|
|
97
|
+
let target;
|
|
98
|
+
for (const item of open) {
|
|
99
|
+
if (mismatchedChildren.has(item.pr) || item.action !== "merge")
|
|
100
|
+
break;
|
|
101
|
+
target = item;
|
|
102
|
+
}
|
|
103
|
+
return target;
|
|
104
|
+
}
|
|
105
|
+
function positionOf(result, pr) {
|
|
106
|
+
return result.prs.find((item) => item.pr === pr)?.stack?.position ?? Number.MAX_SAFE_INTEGER;
|
|
107
|
+
}
|
|
108
|
+
function pollOneLayer(item) {
|
|
109
|
+
if (!item.pollCommand) {
|
|
110
|
+
return {
|
|
111
|
+
action: "escalate",
|
|
112
|
+
instructions: [
|
|
113
|
+
`1. PR #${item.pr} needs attention, but GitHub returned no one-PR poll command.`,
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
action: item.action,
|
|
119
|
+
instructions: [
|
|
120
|
+
`1. Work on the lowest actionable layer, PR #${item.pr}: run \`${item.pollCommand}\`.`,
|
|
121
|
+
"2. Follow that one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
|
|
122
|
+
"3. Rerun the aggregate `--stack` selector before acting on a higher layer.",
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function waitingStack(result) {
|
|
127
|
+
if (result.quotaWarning) {
|
|
128
|
+
return {
|
|
129
|
+
action: "wait",
|
|
130
|
+
instructions: [
|
|
131
|
+
buildQuotaAwareContinuation(result.quotaWarning, "1. This native stack is non-terminal. Before continuing,"),
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
action: "wait",
|
|
137
|
+
instructions: ["1. Recheck this native stack after the lowest open layer changes state."],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function summaryStatusSignature(result) {
|
|
2
|
+
return JSON.stringify({
|
|
3
|
+
nextAction: result.nextAction,
|
|
4
|
+
stackAncestry: result.stackAncestry,
|
|
5
|
+
prs: result.prs.map((item) => ({
|
|
6
|
+
pr: item.pr,
|
|
7
|
+
action: item.action,
|
|
8
|
+
state: item.state,
|
|
9
|
+
mergeable: item.mergeable,
|
|
10
|
+
mergeStateStatus: item.mergeStateStatus,
|
|
11
|
+
reviewDecision: item.reviewDecision,
|
|
12
|
+
checks: item.checks,
|
|
13
|
+
review: item.review,
|
|
14
|
+
})),
|
|
15
|
+
});
|
|
16
|
+
}
|