pr-shepherd 0.51.1 → 0.52.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 +25 -17
- package/bin/cli/iterate-lean.mjs +0 -3
- package/bin/cli/poll-summary-emitter.mjs +2 -3
- package/bin/cli/poll-summary-formatter.mjs +9 -2
- package/bin/commands/check.mjs +1 -0
- package/bin/commands/iterate/escalate.mjs +0 -13
- package/bin/commands/iterate/fix-code.d.mts +2 -0
- package/bin/commands/iterate/fix-code.mjs +14 -42
- package/bin/commands/iterate/index.mjs +140 -3
- package/bin/commands/iterate/merge-state.mjs +33 -18
- package/bin/commands/iterate/parent-first.d.mts +9 -0
- package/bin/commands/iterate/parent-first.mjs +57 -0
- package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
- package/bin/commands/iterate/stale-ancestry.mjs +37 -0
- package/bin/commands/poll-summary-instructions.mjs +179 -99
- package/bin/commands/poll-summary.mjs +28 -4
- package/bin/exit-codes.d.mts +2 -0
- package/bin/exit-codes.mjs +2 -0
- package/bin/github/batch-parsers.mjs +1 -0
- package/bin/github/batch-raw-rules.d.mts +3 -0
- package/bin/github/gql/poll-summary-fragment.gql +54 -0
- package/bin/github/gql/pr-merge-policy.gql +3 -0
- package/bin/github/poll-summary-fingerprint.d.mts +8 -0
- package/bin/github/poll-summary-fingerprint.mjs +28 -0
- package/bin/github/poll-summary-projector.mjs +67 -14
- package/bin/github/poll-summary-queue-removal.d.mts +5 -0
- package/bin/github/poll-summary-queue-removal.mjs +16 -0
- package/bin/github/poll-summary-raw.d.mts +33 -0
- package/bin/github/poll-summary-readiness.d.mts +6 -0
- package/bin/github/poll-summary-readiness.mjs +25 -0
- package/bin/github/poll-summary.d.mts +3 -0
- package/bin/github/poll-summary.mjs +5 -0
- package/bin/state/ready-receipts.d.mts +45 -0
- package/bin/state/ready-receipts.mjs +86 -0
- package/bin/types/escalate.d.mts +2 -3
- package/bin/types/github.d.mts +2 -0
- package/bin/types/poll-summary.d.mts +12 -2
- package/bin/types/report.d.mts +2 -0
- package/package.json +2 -2
- 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 +3 -3
- package/bin/state/bot-cr-seen.d.mts +0 -51
- package/bin/state/bot-cr-seen.mjs +0 -100
package/README.md
CHANGED
|
@@ -36,7 +36,10 @@ Each tick returns exactly one action:
|
|
|
36
36
|
- `FIX_CODE` — agent work is required; complete it, push when needed, then continue polling. Push access to the PR head branch is a usage precondition.
|
|
37
37
|
- `MERGE` — run the emitted head-pinned auto-merge or queue command. Ordinary merges include a plain-merge fallback; queue merges include a GraphQL enqueue fallback. GitHub is authoritative for the result and reports any authorization failure.
|
|
38
38
|
- `CANCEL` — stop polling because the PR merged, closed, or completed its ready-delay.
|
|
39
|
-
- `ESCALATE` — stop polling until a human provides direction.
|
|
39
|
+
- `ESCALATE` — stop polling until a human provides direction. Native stacks reach this only after their autonomous one-PR sessions are exhausted.
|
|
40
|
+
|
|
41
|
+
Native-stack summaries additionally use stack-level `SHEPHERD`: run the listed one-PR sessions,
|
|
42
|
+
then recheck the stack. It is not a per-PR `FIX_CODE` action.
|
|
40
43
|
|
|
41
44
|
Example shape:
|
|
42
45
|
|
|
@@ -79,7 +82,7 @@ Conversations Resolved: No [Not Required]
|
|
|
79
82
|
9. `[FIX_CODE]` is non-terminal: if you changed code, commit and push to the PR head branch, then run review mutations using the pushed commit SHA and iterate immediately with the same options; without code changes, complete the authorized review mutations and iterate immediately.
|
|
80
83
|
```
|
|
81
84
|
|
|
82
|
-
See [docs/actions.md](docs/actions.md) for the complete output contract and [docs/escalations.md](docs/escalations.md) for the exact finite human-handoff boundary. Iterate/poll PR outcomes use exit codes `0` and `10`–`
|
|
85
|
+
See [docs/actions.md](docs/actions.md) for the complete output contract and [docs/escalations.md](docs/escalations.md) for the exact finite human-handoff boundary. Iterate/poll PR outcomes use exit codes `0` and `10`–`16`; command and GitHub failures use `sysexits.h` codes — [docs/exit-codes.md](docs/exit-codes.md).
|
|
83
86
|
|
|
84
87
|
## Workflow Assumptions
|
|
85
88
|
|
|
@@ -129,7 +132,7 @@ pr-shepherd 42 # poll until non-WAIT or timeout
|
|
|
129
132
|
pr-shepherd 42 --interval 60s --timeout 270s
|
|
130
133
|
pr-shepherd 42 --quiet-status # print only changed WAIT status snapshots
|
|
131
134
|
pr-shepherd 42 --until-terminal # continue through WAIT/MARK_READY until work or terminal state
|
|
132
|
-
pr-shepherd 42 --debounce 5m # wait 5m after first FIX_CODE, then return one batched tick
|
|
135
|
+
pr-shepherd 42 --debounce 5m # wait 5m after first FIX_CODE or stack SHEPHERD, then return one batched tick
|
|
133
136
|
pr-shepherd 42 --ready-delay 15m
|
|
134
137
|
pr-shepherd 42 --merge # request head-pinned auto-merge/queue; GitHub reports the result
|
|
135
138
|
pr-shepherd iterate 42 # single tick
|
|
@@ -139,20 +142,25 @@ pr-shepherd 42 43 44 # summarize an explicit same-repository s
|
|
|
139
142
|
pr-shepherd --stack 43 # summarize every PR in a native GitHub stack
|
|
140
143
|
```
|
|
141
144
|
|
|
142
|
-
Multi-PR and `--stack` polling use compact, read-only GraphQL summaries. They return when
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
145
|
+
Multi-PR and `--stack` polling use compact, read-only GraphQL summaries. They return when work is
|
|
146
|
+
needed, every selected PR is complete, the bounded timeout expires, or `--until-terminal` crosses a
|
|
147
|
+
configured GraphQL quota-warning band. Explicit PR sets give each actionable row an exact single-PR
|
|
148
|
+
`pollCommand`, so independent rows can proceed before the next aggregate poll.
|
|
149
|
+
|
|
150
|
+
Native-stack rows are ordered bottom-to-top. `--stack` never performs a mutation itself; only
|
|
151
|
+
`--stack --merge` can emit a complete-stack merge command for the agent. An unready layer (draft, missing a READY
|
|
152
|
+
receipt, conflicting, failing, or stale) returns stack-level `SHEPHERD` with one-PR Shepherd instructions for
|
|
153
|
+
the affected layers. A draft or other unready lower layer marks every higher open layer with
|
|
154
|
+
`blockedByPr`; review and CI sessions on independent layers may proceed concurrently, but an upper
|
|
155
|
+
draft cannot transition to ready until every lower layer has its READY receipt. A queued stack
|
|
156
|
+
returns `WAIT`. A terminal READY or fully merged stack returns `CANCEL`. Closed or unverified
|
|
157
|
+
topology returns `ESCALATE` for human direction after any other shepherdable PRs are handled;
|
|
158
|
+
until then, `SHEPHERD` remains the immediate action and lists the human blockers too.
|
|
159
|
+
|
|
160
|
+
With `--stack --merge`, a fully reconciled and READY stack returns `MERGE` with a `gh stack merge`
|
|
161
|
+
command for the agent to run, checking and installing the optional `github/gh-stack` extension first
|
|
162
|
+
when necessary. It then rechecks until every layer merges and returns `CANCEL`. API and MCP
|
|
163
|
+
aggregate calls perform one summary tick and leave recurrence to the caller.
|
|
156
164
|
|
|
157
165
|
Polling defaults can be set under `poll` in `.pr-shepherdrc.yml`: `intervalSeconds`, `timeoutSeconds`, `debounceSeconds`, and `quietStatus`. Explicit flags override configuration, including `--no-quiet-status` when a shared config enables quiet output. Quiet status remains off by default.
|
|
158
166
|
|
package/bin/cli/iterate-lean.mjs
CHANGED
|
@@ -183,9 +183,6 @@ export function projectIterateLean(result, opts) {
|
|
|
183
183
|
...(result.escalate.mergeQueueRemoval && {
|
|
184
184
|
mergeQueueRemoval: result.escalate.mergeQueueRemoval,
|
|
185
185
|
}),
|
|
186
|
-
...(result.escalate.stack && {
|
|
187
|
-
stack: result.escalate.stack,
|
|
188
|
-
}),
|
|
189
186
|
...(result.escalate.authorization &&
|
|
190
187
|
result.escalate.authorization.length > 0 && {
|
|
191
188
|
authorization: result.escalate.authorization,
|
|
@@ -12,13 +12,12 @@ function pollSummaryExitCode(result) {
|
|
|
12
12
|
if (result.nextAction) {
|
|
13
13
|
const stackExitCode = {
|
|
14
14
|
escalate: EXIT.ESCALATE,
|
|
15
|
-
|
|
15
|
+
shepherd: EXIT.SHEPHERD,
|
|
16
16
|
merge: EXIT.MERGE,
|
|
17
|
-
mark_ready: EXIT.MARK_READY,
|
|
18
17
|
wait: EXIT.WAIT,
|
|
19
18
|
cancel: EXIT.OK,
|
|
20
19
|
};
|
|
21
|
-
return stackExitCode[result.nextAction]
|
|
20
|
+
return stackExitCode[result.nextAction];
|
|
22
21
|
}
|
|
23
22
|
const actions = new Set(result.prs.map((item) => item.action));
|
|
24
23
|
if (actions.has("escalate"))
|
|
@@ -7,7 +7,7 @@ export function formatPollSummaryResult(result) {
|
|
|
7
7
|
const lines = [
|
|
8
8
|
`# Poll summary [${result.reason.toUpperCase()}]`,
|
|
9
9
|
"",
|
|
10
|
-
`**repo** \`${result.repo}\` · **selection** ${selection} · **mode** \`${result.mode}\`${result.nextAction ? ` · **next action** \`${result.nextAction}\`` : ""}`,
|
|
10
|
+
`**repo** \`${result.repo}\` · **selection** ${selection} · **mode** \`${result.mode}\`${result.stackMergeable !== undefined ? ` · **stack mergeable** \`${result.stackMergeable}\`` : ""}${result.nextAction ? ` · **next action** \`${result.nextAction}\`` : ""}`,
|
|
11
11
|
"",
|
|
12
12
|
"## Pull requests",
|
|
13
13
|
"",
|
|
@@ -42,11 +42,13 @@ function formatItem(item) {
|
|
|
42
42
|
? " · blocking reviewer `in progress`"
|
|
43
43
|
: "";
|
|
44
44
|
const readyDelay = item.remainingSeconds !== undefined ? ` · ready delay \`${item.remainingSeconds}s\`` : "";
|
|
45
|
+
const readyReceipt = item.readyReceipt ? " · Shepherd READY completion `verified`" : "";
|
|
46
|
+
const blockedBy = item.blockedByPr ? ` · stack blocked by PR #${item.blockedByPr}` : "";
|
|
45
47
|
const checks = item.checks;
|
|
46
48
|
const review = item.review;
|
|
47
49
|
return [
|
|
48
50
|
`- [PR #${item.pr}: ${escapeMarkdownText(item.title)}](${item.url}) [${item.action.toUpperCase()}]`,
|
|
49
|
-
` - state \`${item.state}\` · mergeable \`${item.mergeable}\` · merge \`${item.mergeStateStatus}\`${reviewDecision}${stateFlags}${blockingReviewer}${readyDelay}${stack}`,
|
|
51
|
+
` - state \`${item.state}\` · mergeable \`${item.mergeable}\` · merge \`${item.mergeStateStatus}\`${reviewDecision}${stateFlags}${blockingReviewer}${readyDelay}${readyReceipt}${blockedBy}${stack}`,
|
|
50
52
|
` - head \`${item.headRefName}\` at \`${item.headRefOid}\` · base \`${item.baseRefName}\``,
|
|
51
53
|
...(checks
|
|
52
54
|
? [` - checks: ${formatCounts(checks, checks.incomplete ? ", incomplete" : "")}`]
|
|
@@ -54,6 +56,11 @@ function formatItem(item) {
|
|
|
54
56
|
...(review
|
|
55
57
|
? [` - review: ${formatCounts(review, review.incomplete ? ", incomplete" : "")}`]
|
|
56
58
|
: []),
|
|
59
|
+
...(item.queueRemoval
|
|
60
|
+
? [
|
|
61
|
+
` - queue removal: reason \`${item.queueRemoval.reason ?? "UNKNOWN"}\` · at \`${item.queueRemoval.createdAtUnix}\`${item.queueRemoval.actor ? ` · actor \`@${item.queueRemoval.actor}\`` : ""}${item.queueRemoval.beforeCommitOid ? ` · commit \`${item.queueRemoval.beforeCommitOid}\`` : ""}${item.queueRemoval.beforeCommitParentOids?.length ? ` · parents \`${item.queueRemoval.beforeCommitParentOids.join(",")}\`` : ""}`,
|
|
62
|
+
]
|
|
63
|
+
: []),
|
|
57
64
|
` - reasons: ${item.reasons.map((reason) => `\`${reason}\``).join(", ")}`,
|
|
58
65
|
...(item.pollCommand ? [` - pollCommand: \`${item.pollCommand}\``] : []),
|
|
59
66
|
].join("\n");
|
package/bin/commands/check.mjs
CHANGED
|
@@ -249,6 +249,7 @@ export async function runCheck(opts) {
|
|
|
249
249
|
...(batchData.viewerAuthorization && { viewerAuthorization: batchData.viewerAuthorization }),
|
|
250
250
|
status,
|
|
251
251
|
baseBranch: batchData.baseRefName,
|
|
252
|
+
...(batchData.baseRefOid && { baseRefOid: batchData.baseRefOid }),
|
|
252
253
|
mergeStatus,
|
|
253
254
|
checks: {
|
|
254
255
|
passing: merged.passing,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { loadConfig } from "../../config/load.mjs";
|
|
2
|
-
import { inlineCode } from "../../util/markdown.mjs";
|
|
3
2
|
import { renderResolveCommand } from "./render.mjs";
|
|
4
3
|
function renderEscalateAuthor(item) {
|
|
5
4
|
return [`@${item.author}`, item.authorType, item.authorAssociation].filter(Boolean).join(" · ");
|
|
@@ -194,10 +193,6 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
|
|
|
194
193
|
if (removal.beforeCommitOid)
|
|
195
194
|
lines.push(`- queue commit: \`${removal.beforeCommitOid}\``);
|
|
196
195
|
}
|
|
197
|
-
if (escalate.stack) {
|
|
198
|
-
const s = escalate.stack;
|
|
199
|
-
lines.push("", "## GitHub stack", "", `- layer: \`${s.position}\` of \`${s.size}\` in stack \`${s.number}\``, `- stack base: ${inlineCode(s.baseRefName)}`);
|
|
200
|
-
}
|
|
201
196
|
if (escalate.authorization && escalate.authorization.length > 0) {
|
|
202
197
|
lines.push("");
|
|
203
198
|
lines.push("## Authorization");
|
|
@@ -231,10 +226,6 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
|
|
|
231
226
|
return lines.join("\n");
|
|
232
227
|
}
|
|
233
228
|
export function buildEscalateSuggestion(triggers, detail) {
|
|
234
|
-
if (triggers.includes("stacked-pr")) {
|
|
235
|
-
const selector = detail ?? "<pr>";
|
|
236
|
-
return `This PR belongs to a GitHub stack, so Shepherd will not emit a merge command. \`gh pr merge\` targets the PR's own base branch — for a mid-stack layer that is the unmerged parent branch, not the stack's base — and auto-merge is unsupported on stacked PRs. Merge from the GitHub stack UI, or run \`gh stack merge --squash ${selector}\` (requires the \`github/gh-stack\` extension — run \`gh extension install github/gh-stack\` first if it's not installed), which lands this PR and every unmerged layer below it.`;
|
|
237
|
-
}
|
|
238
229
|
if (triggers.includes("check-follow-up-unavailable")) {
|
|
239
230
|
return "One or more failing checks have no autonomous follow-up available. Use the displayed conclusion, run or URL, and included evidence to handle them manually.";
|
|
240
231
|
}
|
|
@@ -257,9 +248,5 @@ export function buildEscalateSuggestion(triggers, detail) {
|
|
|
257
248
|
const attempts = loadConfig().iterate.fixAttemptsPerThread;
|
|
258
249
|
return `The same thread(s) remain unresolved after their pending review commands were returned for ${attempts} FIX_CODE ticks. Automated iteration is paused for a manual decision.`;
|
|
259
250
|
}
|
|
260
|
-
if (triggers.includes("bot-cr-not-dismissed")) {
|
|
261
|
-
const ids = detail ? ` (review IDs: ${detail})` : "";
|
|
262
|
-
return `Bot CHANGES_REQUESTED review(s) remained undismissed past the stall window${ids}. The agent likely dropped \`--dismiss-review-ids\` from a prior apply command. Dismiss the review(s) manually (or re-run \`pr-shepherd apply review\` with the IDs) to unblock the PR.`;
|
|
263
|
-
}
|
|
264
251
|
return "Ambiguous state — automated handling cannot proceed safely. Inspect the PR and act manually.";
|
|
265
252
|
}
|
|
@@ -20,6 +20,8 @@ interface HandleFixCodeContext {
|
|
|
20
20
|
surfacedApprovals: Review[];
|
|
21
21
|
botUsernames: NormalizedBotUsernames;
|
|
22
22
|
ruleAutoResolveThreadIds?: string[];
|
|
23
|
+
/** Verified stack-repair guidance, when ancestry is stale. */
|
|
24
|
+
repairInstructions?: string[];
|
|
23
25
|
}
|
|
24
26
|
export declare function handleFixCode(ctx: HandleFixCodeContext): Promise<IterateResult>;
|
|
25
27
|
export {};
|
|
@@ -1,6 +1,5 @@
|
|
|
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";
|
|
4
3
|
import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
|
|
5
4
|
import { hashBody, markSeen } from "../../state/seen-comments.mjs";
|
|
6
5
|
import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
|
|
@@ -17,15 +16,15 @@ import { formatPrUrl } from "../../pr-reference.mjs";
|
|
|
17
16
|
function checkRequiresHumanFollowUp(check) {
|
|
18
17
|
if (check.rerunCommand)
|
|
19
18
|
return false;
|
|
20
|
-
// Once GitHub advances beyond the original attempt, Shepherd's one autonomous rerun has
|
|
21
|
-
// already been consumed. Hand the repeated failure off even when logs are available; the
|
|
22
|
-
// human still receives that evidence in the escalation payload.
|
|
23
|
-
if (check.runAttempt !== undefined && check.runAttempt > 1)
|
|
24
|
-
return true;
|
|
25
19
|
if (check.conclusion === "ACTION_REQUIRED" ||
|
|
26
20
|
check.conclusion === "CANCELLED" ||
|
|
27
21
|
check.conclusion === "STARTUP_FAILURE")
|
|
28
22
|
return true;
|
|
23
|
+
// A later attempt cannot be rerun automatically, but its included log can still
|
|
24
|
+
// identify a code or configuration fix for the agent. Only a later failure with
|
|
25
|
+
// no actionable evidence needs a human handoff.
|
|
26
|
+
if (check.runAttempt !== undefined && check.runAttempt > 1)
|
|
27
|
+
return !check.logExcerpt?.trim();
|
|
29
28
|
// An external check's direct URL is actionable evidence: the agent can inspect the
|
|
30
29
|
// provider and/or reproduce the reported failure locally. Only a truly bare check
|
|
31
30
|
// has no autonomous investigation path.
|
|
@@ -68,7 +67,7 @@ function pendingReviewCommands(resolveCommand, resolveOnlyCommand) {
|
|
|
68
67
|
return Object.keys(pending).length > 0 ? pending : undefined;
|
|
69
68
|
}
|
|
70
69
|
export async function handleFixCode(ctx) {
|
|
71
|
-
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, } = ctx;
|
|
70
|
+
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
|
|
72
71
|
const prReference = formatPrUrl(report.repo, prNumber);
|
|
73
72
|
const failingChecks = report.checks.failing;
|
|
74
73
|
const annotatedExtra = checksWithActionableAnnotations(report).filter((c) => c.category !== "failing");
|
|
@@ -105,37 +104,6 @@ export async function handleFixCode(ctx) {
|
|
|
105
104
|
...(report.comments.minimizeIds ?? report.comments.actionable.map((comment) => comment.id)),
|
|
106
105
|
...reviewSummaryIds,
|
|
107
106
|
], changesRequestedReviewsForWork, checks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads, resolveOtherHumanThreads);
|
|
108
|
-
const botCrReviews = report.changesRequestedReviews.filter((r) => (!isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames)) &&
|
|
109
|
-
report.viewerAuthorization?.viewerCanAdminister === true);
|
|
110
|
-
const botCrStateKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
111
|
-
const previousBotCrState = await readBotCrSeenState(botCrStateKey);
|
|
112
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
113
|
-
const { next: nextBotCrState, staleIds: staleBotCrIds } = updateBotCrSeenState(previousBotCrState, botCrReviews, nowSeconds, stallTimeoutSeconds);
|
|
114
|
-
await writeBotCrSeenState(botCrStateKey, nextBotCrState);
|
|
115
|
-
if (staleBotCrIds.length > 0) {
|
|
116
|
-
const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(toAgentChecks(failingChecks));
|
|
117
|
-
const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
|
|
118
|
-
const escalateBase = {
|
|
119
|
-
triggers: ["bot-cr-not-dismissed"],
|
|
120
|
-
unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
|
|
121
|
-
ambiguousComments: report.comments.actionable.map(toAgentComment),
|
|
122
|
-
changesRequestedReviews: report.changesRequestedReviews,
|
|
123
|
-
...(firstLookSummaries.length > 0 && { firstLookSummaries }),
|
|
124
|
-
...(editedSummaries.length > 0 && { editedSummaries }),
|
|
125
|
-
...(pending && { pendingReviewCommands: pending }),
|
|
126
|
-
suggestion: buildEscalateSuggestion(["bot-cr-not-dismissed"], staleBotCrIds.join(", ")),
|
|
127
|
-
};
|
|
128
|
-
return {
|
|
129
|
-
...base,
|
|
130
|
-
action: "escalate",
|
|
131
|
-
escalate: {
|
|
132
|
-
...escalateBase,
|
|
133
|
-
humanMessage: buildEscalateHumanMessage(escalateBase, prReference, {
|
|
134
|
-
merge: opts.merge,
|
|
135
|
-
}),
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
107
|
const escalateTriggers = countFixCodeAttempt
|
|
140
108
|
? checkEscalateTriggers(retryableActionableThreads, priorThreadAttempts)
|
|
141
109
|
: { triggers: [], thrashHistory: undefined };
|
|
@@ -216,8 +184,9 @@ export async function handleFixCode(ctx) {
|
|
|
216
184
|
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
217
185
|
const belongsToActiveWorkflowRun = (check) => check.runId !== null && inProgressWorkflowRunIds.has(check.runId);
|
|
218
186
|
const manualFollowUpChecks = failingAgentChecks.filter((check) => !belongsToActiveWorkflowRun(check) && checkRequiresHumanFollowUp(check));
|
|
219
|
-
const exhaustedAttempts =
|
|
220
|
-
const
|
|
187
|
+
const exhaustedAttempts = failingAgentChecks.filter((check) => check.runAttempt !== undefined && check.runAttempt > 1);
|
|
188
|
+
const manualExhaustedAttempts = exhaustedAttempts.filter((check) => manualFollowUpChecks.includes(check));
|
|
189
|
+
const hasBehindBaseRecovery = isBehind && manualExhaustedAttempts.length > 0;
|
|
221
190
|
const hasAutonomousWork = hasConflicts ||
|
|
222
191
|
hasBehindBaseRecovery ||
|
|
223
192
|
threads.length > 0 ||
|
|
@@ -235,8 +204,8 @@ export async function handleFixCode(ctx) {
|
|
|
235
204
|
if (manualFollowUpChecks.length > 0 && !hasAutonomousWork) {
|
|
236
205
|
const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(failingAgentChecks);
|
|
237
206
|
const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
|
|
238
|
-
const checkSuggestion =
|
|
239
|
-
? `GitHub reports a later workflow attempt (${
|
|
207
|
+
const checkSuggestion = manualExhaustedAttempts.length > 0
|
|
208
|
+
? `GitHub reports a later workflow attempt (${manualExhaustedAttempts
|
|
240
209
|
.map((check) => `${check.runId ?? check.name}: attempt ${check.runAttempt}`)
|
|
241
210
|
.join(", ")}), so Shepherd's single rerun allowance is exhausted. Use the included evidence to handle the repeated failure manually before resuming.`
|
|
242
211
|
: buildEscalateSuggestion(["check-follow-up-unavailable"]);
|
|
@@ -303,6 +272,9 @@ export async function handleFixCode(ctx) {
|
|
|
303
272
|
const firstLookThreads = report.threads.firstLook;
|
|
304
273
|
const firstLookComments = report.comments.firstLook;
|
|
305
274
|
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, exhaustedAttempts.length > 0);
|
|
275
|
+
if (repairInstructions && repairInstructions.length > 0) {
|
|
276
|
+
instructions.unshift(...repairInstructions);
|
|
277
|
+
}
|
|
306
278
|
const prospectiveResult = {
|
|
307
279
|
...base,
|
|
308
280
|
baseBranch: baseLookup.branch,
|
|
@@ -16,6 +16,14 @@ import { buildReadyMergeOutcome, handleActiveMergeState } from "./merge-state.mj
|
|
|
16
16
|
import { buildIterateBase } from "./base.mjs";
|
|
17
17
|
import { markReadyIfAuthorized } from "./mark-ready.mjs";
|
|
18
18
|
import { withIterateApiUsage } from "./run.mjs";
|
|
19
|
+
import { fetchRawSummaryPr } from "../../github/poll-summary.mjs";
|
|
20
|
+
import { summarizePollSummaryPr } from "../../github/poll-summary-projector.mjs";
|
|
21
|
+
import { fingerprintRawSummaryPr } from "../../github/poll-summary-fingerprint.mjs";
|
|
22
|
+
import { currentQueueRemovalEvent } from "../../github/poll-summary-queue-removal.mjs";
|
|
23
|
+
import { isCurrentSummaryReady } from "../../github/poll-summary-readiness.mjs";
|
|
24
|
+
import { clearReadyReceipt, isReadyReceiptCurrent, readReadyReceipt, writeReadyReceipt, } from "../../state/ready-receipts.mjs";
|
|
25
|
+
import { parentBlocksMarkReady } from "./parent-first.mjs";
|
|
26
|
+
import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
|
|
19
27
|
export function runIterate(opts) {
|
|
20
28
|
return withIterateApiUsage(opts, () => runIterateCore(opts));
|
|
21
29
|
}
|
|
@@ -80,8 +88,19 @@ async function runIterateCore(opts) {
|
|
|
80
88
|
editedSummaries.length > 0 ||
|
|
81
89
|
(config.iterate.minimizeApprovals && surfacedApprovals.length > 0);
|
|
82
90
|
const activeMerge = Boolean(opts.merge && (report.mergeQueue?.inQueue || report.mergeQueue?.autoMergeRequest));
|
|
83
|
-
const
|
|
91
|
+
const staleAncestry = await findStaleNativeStackAncestry(report, {
|
|
92
|
+
owner: repoOwner,
|
|
93
|
+
name: repoName,
|
|
94
|
+
});
|
|
95
|
+
const isCleanReadyState = report.status === "READY" &&
|
|
96
|
+
!report.mergeStatus.isDraft &&
|
|
97
|
+
!hasActionableWork &&
|
|
98
|
+
!activeMerge &&
|
|
99
|
+
staleAncestry === null;
|
|
84
100
|
const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName);
|
|
101
|
+
if (report.mergeStatus.mergeRequirements?.stack) {
|
|
102
|
+
await invalidateStaleReadyReceipt({ owner: repoOwner, repo: repoName, pr: report.pr }, report, hasActionableWork);
|
|
103
|
+
}
|
|
85
104
|
const base = buildIterateBase(report, readyState);
|
|
86
105
|
const headSha = (await getCurrentHeadSha()) ?? "unknown";
|
|
87
106
|
// Checks (including merge-queue synthetic-commit checks) and hard conflicts are signals
|
|
@@ -126,13 +145,50 @@ async function runIterateCore(opts) {
|
|
|
126
145
|
});
|
|
127
146
|
if (mergeStateResult)
|
|
128
147
|
return mergeStateResult;
|
|
148
|
+
if (staleAncestry) {
|
|
149
|
+
return handleFixCode({
|
|
150
|
+
base,
|
|
151
|
+
report,
|
|
152
|
+
opts: { ...opts, prNumber, neverCancelRuns },
|
|
153
|
+
headSha,
|
|
154
|
+
stallKey,
|
|
155
|
+
prNumber,
|
|
156
|
+
stallTimeoutSeconds,
|
|
157
|
+
repoOwner,
|
|
158
|
+
repoName,
|
|
159
|
+
reviewSummaryIds,
|
|
160
|
+
firstLookSummaries,
|
|
161
|
+
editedSummaries,
|
|
162
|
+
surfacedApprovals,
|
|
163
|
+
botUsernames,
|
|
164
|
+
repairInstructions: staleAncestry.instructions,
|
|
165
|
+
ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
129
168
|
const canMarkReady = report.status === "READY" &&
|
|
130
169
|
report.mergeStatus.isDraft &&
|
|
131
170
|
!report.mergeStatus.blockingBotReviewInProgress;
|
|
132
|
-
const
|
|
171
|
+
const blockedByParent = canMarkReady
|
|
172
|
+
? await parentBlocksMarkReady(report, { owner: repoOwner, name: repoName })
|
|
173
|
+
: false;
|
|
174
|
+
const markReadyResult = await markReadyIfAuthorized(canMarkReady && !blockedByParent && !opts.noAutoMarkReady && config.actions.autoMarkReady, base, report);
|
|
133
175
|
if (markReadyResult)
|
|
134
176
|
return markReadyResult;
|
|
135
|
-
if (readyState.shouldCancel) {
|
|
177
|
+
if (readyState.shouldCancel && !report.mergeStatus.isDraft) {
|
|
178
|
+
const needsStackReceipt = report.mergeStatus.mergeRequirements?.stack !== undefined;
|
|
179
|
+
const receiptWritten = needsStackReceipt
|
|
180
|
+
? await recordReadyReceipt({ owner: repoOwner, repo: repoName, pr: report.pr }, report)
|
|
181
|
+
: true;
|
|
182
|
+
if (!receiptWritten) {
|
|
183
|
+
const receiptWait = {
|
|
184
|
+
...base,
|
|
185
|
+
action: "wait",
|
|
186
|
+
shouldCancel: false,
|
|
187
|
+
remainingSeconds: readyDelaySeconds,
|
|
188
|
+
log: `WAIT: PR #${base.pr} reached ready-delay but its stack readiness receipt could not be persisted`,
|
|
189
|
+
};
|
|
190
|
+
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, receiptWait, report, reviewSummaryIds);
|
|
191
|
+
}
|
|
136
192
|
await clearStallState(stallKey);
|
|
137
193
|
const mergeResult = buildReadyMergeOutcome(opts.merge, true, base, report);
|
|
138
194
|
if (mergeResult)
|
|
@@ -147,3 +203,84 @@ async function runIterateCore(opts) {
|
|
|
147
203
|
}
|
|
148
204
|
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, { ...base, action: "wait", log: buildWaitLog(base) }, report, reviewSummaryIds);
|
|
149
205
|
}
|
|
206
|
+
async function recordReadyReceipt(key, report) {
|
|
207
|
+
if (report.status !== "READY" ||
|
|
208
|
+
report.mergeStatus.state !== "OPEN" ||
|
|
209
|
+
report.mergeStatus.isDraft ||
|
|
210
|
+
!report.headSha ||
|
|
211
|
+
!report.baseRefOid)
|
|
212
|
+
return false;
|
|
213
|
+
try {
|
|
214
|
+
const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
|
|
215
|
+
const fingerprint = fingerprintRawSummaryPr(raw);
|
|
216
|
+
if (fingerprint === null ||
|
|
217
|
+
raw.state !== "OPEN" ||
|
|
218
|
+
raw.isDraft ||
|
|
219
|
+
raw.headRefOid !== report.headSha ||
|
|
220
|
+
raw.baseRefOid !== report.baseRefOid)
|
|
221
|
+
return false;
|
|
222
|
+
const summary = await summarizePollSummaryPr(raw, { owner: key.owner, name: key.repo }, { stackPrNumber: report.pr });
|
|
223
|
+
if (!isCurrentSummaryReady(raw, summary.checks ?? {}, summary.review ?? {}))
|
|
224
|
+
return false;
|
|
225
|
+
const removalEvent = currentQueueRemovalEvent(raw);
|
|
226
|
+
await writeReadyReceipt({
|
|
227
|
+
version: 1,
|
|
228
|
+
...key,
|
|
229
|
+
headRefOid: raw.headRefOid,
|
|
230
|
+
baseRefOid: raw.baseRefOid,
|
|
231
|
+
status: "READY",
|
|
232
|
+
isDraft: false,
|
|
233
|
+
readinessFingerprint: fingerprint,
|
|
234
|
+
...(removalEvent?.id && {
|
|
235
|
+
acknowledgedQueueRemovalId: removalEvent.id,
|
|
236
|
+
}),
|
|
237
|
+
recordedAtUnix: Math.floor(Date.now() / 1000),
|
|
238
|
+
});
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// The receipt is supplementary evidence. A transient summary fetch or
|
|
243
|
+
// state-directory failure must not turn a completed one-PR poll into a
|
|
244
|
+
// different Shepherd action.
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function invalidateStaleReadyReceipt(key, report, hasActionableWork) {
|
|
249
|
+
const receipt = await readReadyReceipt(key);
|
|
250
|
+
if (!receipt)
|
|
251
|
+
return;
|
|
252
|
+
const retainQueuedReceipt = report.mergeQueue?.inQueue === true &&
|
|
253
|
+
report.mergeStatus.state === "OPEN" &&
|
|
254
|
+
!report.mergeStatus.isDraft &&
|
|
255
|
+
Boolean(report.headSha && report.baseRefOid);
|
|
256
|
+
if (report.mergeStatus.state !== "OPEN" ||
|
|
257
|
+
report.mergeStatus.isDraft ||
|
|
258
|
+
!report.headSha ||
|
|
259
|
+
!report.baseRefOid ||
|
|
260
|
+
(!retainQueuedReceipt && (report.status !== "READY" || hasActionableWork))) {
|
|
261
|
+
await clearReadyReceipt(key);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
|
|
266
|
+
// Queue predecessors can advance the target branch without changing this
|
|
267
|
+
// PR's source. Compare against the receipt's base only while both fresh
|
|
268
|
+
// views still place the PR in the queue.
|
|
269
|
+
const queuedBaseAdvanced = retainQueuedReceipt && raw.isInMergeQueue;
|
|
270
|
+
const fingerprint = fingerprintRawSummaryPr(queuedBaseAdvanced ? { ...raw, baseRefOid: receipt.baseRefOid } : raw);
|
|
271
|
+
if (fingerprint === null ||
|
|
272
|
+
!isReadyReceiptCurrent(receipt, {
|
|
273
|
+
headRefOid: raw.headRefOid,
|
|
274
|
+
baseRefOid: queuedBaseAdvanced ? receipt.baseRefOid : raw.baseRefOid,
|
|
275
|
+
readinessFingerprint: fingerprint,
|
|
276
|
+
status: "READY",
|
|
277
|
+
isDraft: raw.isDraft,
|
|
278
|
+
})) {
|
|
279
|
+
await clearReadyReceipt(key);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// Fail closed: an unreadable current snapshot cannot validate old evidence.
|
|
284
|
+
await clearReadyReceipt(key);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -2,26 +2,41 @@ import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
|
2
2
|
import { buildEscalateHumanMessage, buildEscalateSuggestion } from "./escalate.mjs";
|
|
3
3
|
import { buildMergeCommandPlan } from "./merge.mjs";
|
|
4
4
|
import { formatPrUrl } from "../../pr-reference.mjs";
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
ambiguousComments: [],
|
|
12
|
-
changesRequestedReviews: [],
|
|
13
|
-
stack,
|
|
14
|
-
suggestion: buildEscalateSuggestion(["stacked-pr"], String(report.pr)),
|
|
15
|
-
};
|
|
5
|
+
/**
|
|
6
|
+
* A one-PR poll cannot prove every native-stack layer is ready or linear. Route it through the
|
|
7
|
+
* aggregate stack selector, which does that reconciliation before emitting the whole-stack merge.
|
|
8
|
+
*/
|
|
9
|
+
function buildStackedRouteResult(base, report, stack) {
|
|
10
|
+
const prUrl = formatPrUrl(report.repo, report.pr);
|
|
16
11
|
return {
|
|
17
12
|
...base,
|
|
18
|
-
action: "
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
action: "fix_code",
|
|
14
|
+
fix: {
|
|
15
|
+
threads: [],
|
|
16
|
+
resolutionOnlyThreads: [],
|
|
17
|
+
actionableComments: [],
|
|
18
|
+
reviewSummaryIds: [],
|
|
19
|
+
firstLookSummaries: [],
|
|
20
|
+
editedSummaries: [],
|
|
21
|
+
surfacedApprovals: [],
|
|
22
|
+
checks: [],
|
|
23
|
+
changesRequestedReviews: [],
|
|
24
|
+
resolveCommand: {
|
|
25
|
+
argv: ["pr-shepherd", "apply", "review", prUrl],
|
|
26
|
+
requiresHeadSha: false,
|
|
27
|
+
requiresDismissMessage: false,
|
|
28
|
+
hasMutations: false,
|
|
29
|
+
},
|
|
30
|
+
instructions: [
|
|
31
|
+
`PR #${report.pr} is layer ${stack.position} of ${stack.size} in native stack #${stack.number}; do not run \`gh pr merge\` for this layer.`,
|
|
32
|
+
`Run \`pr-shepherd --stack ${prUrl} --until-terminal --merge\` to reconcile the complete stack and run its emitted whole-stack merge command.`,
|
|
33
|
+
],
|
|
34
|
+
inProgressRunIds: [],
|
|
35
|
+
protectedRuns: [],
|
|
36
|
+
firstLookThreads: [],
|
|
37
|
+
firstLookComments: [],
|
|
24
38
|
},
|
|
39
|
+
cancelled: [],
|
|
25
40
|
};
|
|
26
41
|
}
|
|
27
42
|
export function buildReadyMergeOutcome(enabled, readyElapsed, base, report) {
|
|
@@ -29,7 +44,7 @@ export function buildReadyMergeOutcome(enabled, readyElapsed, base, report) {
|
|
|
29
44
|
return null;
|
|
30
45
|
const stack = report.mergeStatus.mergeRequirements?.stack;
|
|
31
46
|
if (stack)
|
|
32
|
-
return
|
|
47
|
+
return buildStackedRouteResult(base, report, stack);
|
|
33
48
|
const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
|
|
34
49
|
report.mergeStatus.mergeRequirements?.mergeQueue?.enabled);
|
|
35
50
|
return {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RepoInfo } from "../../github/client.mts";
|
|
2
|
+
import type { ShepherdReport } from "../../types.mts";
|
|
3
|
+
/**
|
|
4
|
+
* Draft children may only be converted after their immediate parent has
|
|
5
|
+
* independently completed a one-PR ready-delay and the stack boundary is
|
|
6
|
+
* still linear. A failed or incomplete stack read blocks this mutation but
|
|
7
|
+
* does not block ordinary review/CI work in the caller.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parentBlocksMarkReady(report: ShepherdReport, repo: RepoInfo): Promise<boolean>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { fetchPollSummary } from "../../github/poll-summary.mjs";
|
|
2
|
+
/**
|
|
3
|
+
* Draft children may only be converted after their immediate parent has
|
|
4
|
+
* independently completed a one-PR ready-delay and the stack boundary is
|
|
5
|
+
* still linear. A failed or incomplete stack read blocks this mutation but
|
|
6
|
+
* does not block ordinary review/CI work in the caller.
|
|
7
|
+
*/
|
|
8
|
+
export async function parentBlocksMarkReady(report, repo) {
|
|
9
|
+
const stack = report.mergeStatus.mergeRequirements?.stack;
|
|
10
|
+
if (!stack || stack.position === 1)
|
|
11
|
+
return false;
|
|
12
|
+
if (stack.position < 1)
|
|
13
|
+
return true;
|
|
14
|
+
try {
|
|
15
|
+
const summary = await fetchPollSummary({ stackPrNumber: report.pr }, repo);
|
|
16
|
+
const child = summary.prs.find((item) => item.pr === report.pr);
|
|
17
|
+
const lowerLayers = summary.prs
|
|
18
|
+
.filter((item) => (item.stack?.position ?? Number.MAX_SAFE_INTEGER) < stack.position)
|
|
19
|
+
.sort((left, right) => (left.stack?.position ?? Number.MAX_SAFE_INTEGER) -
|
|
20
|
+
(right.stack?.position ?? Number.MAX_SAFE_INTEGER));
|
|
21
|
+
if (!child || child.state !== "OPEN" || lowerLayers.length !== stack.position - 1)
|
|
22
|
+
return true;
|
|
23
|
+
// Any stale boundary up through the child means at least one lower layer
|
|
24
|
+
// is no longer the base it was reviewed against. Ignore gaps above this
|
|
25
|
+
// child because they do not affect its immediate promotion boundary.
|
|
26
|
+
const checkedLayers = new Set([report.pr, ...lowerLayers.map((item) => item.pr)]);
|
|
27
|
+
if (summary.stackAncestry?.some((gap) => checkedLayers.has(gap.childPr)))
|
|
28
|
+
return true;
|
|
29
|
+
for (const parent of lowerLayers) {
|
|
30
|
+
// A merged parent is already satisfied; GitHub may have retargeted the
|
|
31
|
+
// child to the trunk as part of the merge.
|
|
32
|
+
if (parent.state === "MERGED")
|
|
33
|
+
continue;
|
|
34
|
+
if (parent.state !== "OPEN")
|
|
35
|
+
return true;
|
|
36
|
+
if (parent.isDraft || parent.mergeable === "CONFLICTING")
|
|
37
|
+
return true;
|
|
38
|
+
if (["DIRTY", "BEHIND", "UNKNOWN"].includes(parent.mergeStateStatus))
|
|
39
|
+
return true;
|
|
40
|
+
// A receipt only establishes readiness after a merge-queue removal once
|
|
41
|
+
// the one-PR session has observed and acknowledged that exact removal.
|
|
42
|
+
// The aggregate projection preserves an unacknowledged removal here, so
|
|
43
|
+
// do not let its otherwise-current receipt promote a child draft.
|
|
44
|
+
if (parent.queueRemoval)
|
|
45
|
+
return true;
|
|
46
|
+
// A parent that looks ready but has not completed its own one-PR receipt
|
|
47
|
+
// is not sufficient evidence for a child draft transition.
|
|
48
|
+
if (parent.readyReceipt !== true)
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Never convert a child draft based on an unverifiable parent.
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|