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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RepoInfo } from "../../github/client.mts";
|
|
2
|
+
import type { PollSummaryStackAncestry } from "../../types.mts";
|
|
3
|
+
import type { ShepherdReport } from "../../types/report.mts";
|
|
4
|
+
/**
|
|
5
|
+
* A verified stale boundary for the PR being shepherded.
|
|
6
|
+
*
|
|
7
|
+
* The aggregate stack read is authoritative for this check: it compares the
|
|
8
|
+
* child's recorded base OID with the current head OID of its immediate open
|
|
9
|
+
* parent. GitHub can report both PRs CLEAN while this boundary is stale.
|
|
10
|
+
*/
|
|
11
|
+
export interface StaleNativeStackAncestry extends PollSummaryStackAncestry {
|
|
12
|
+
instructions: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Find a stale immediate parent boundary for one PR.
|
|
16
|
+
*
|
|
17
|
+
* This helper is intentionally read-only. A null result means either the PR is
|
|
18
|
+
* not a non-root native-stack layer, its boundary is current, or the boundary
|
|
19
|
+
* could not be verified. Callers must not emit a repair command from an
|
|
20
|
+
* unverified snapshot.
|
|
21
|
+
*/
|
|
22
|
+
export declare function findStaleNativeStackAncestry(report: Pick<ShepherdReport, "pr" | "mergeStatus">, repo: RepoInfo): Promise<StaleNativeStackAncestry | null>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { fetchPollSummary } from "../../github/poll-summary.mjs";
|
|
2
|
+
/**
|
|
3
|
+
* Find a stale immediate parent boundary for one PR.
|
|
4
|
+
*
|
|
5
|
+
* This helper is intentionally read-only. A null result means either the PR is
|
|
6
|
+
* not a non-root native-stack layer, its boundary is current, or the boundary
|
|
7
|
+
* could not be verified. Callers must not emit a repair command from an
|
|
8
|
+
* unverified snapshot.
|
|
9
|
+
*/
|
|
10
|
+
export async function findStaleNativeStackAncestry(report, repo) {
|
|
11
|
+
const stack = report.mergeStatus.mergeRequirements?.stack;
|
|
12
|
+
if (!stack || stack.position <= 1)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const summary = await fetchPollSummary({ stackPrNumber: report.pr }, repo);
|
|
16
|
+
const ancestry = summary.stackAncestry?.find((gap) => gap.childPr === report.pr);
|
|
17
|
+
if (!ancestry)
|
|
18
|
+
return null;
|
|
19
|
+
return {
|
|
20
|
+
...ancestry,
|
|
21
|
+
instructions: buildStaleNativeStackAncestryInstructions(repo, ancestry),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// A stale repair is safe only when both OIDs were observed together. Let
|
|
26
|
+
// the caller retain the ordinary WAIT/ESCALATE path on an unreadable stack.
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Build the one-PR repair guidance after a stale boundary was verified. */
|
|
31
|
+
function buildStaleNativeStackAncestryInstructions(repo, ancestry) {
|
|
32
|
+
return [
|
|
33
|
+
`PR #${ancestry.childPr} records base \`${ancestry.childBaseRefName}\` at \`${ancestry.childBaseRefOid}\`, but its open parent PR #${ancestry.parentPr} currently ends at \`${ancestry.parentHeadRefName}\` \`${ancestry.parentHeadRefOid}\`.`,
|
|
34
|
+
`From a clean checkout of \`${repo.owner}/${repo.name}\`, check out the parent stack branch \`${ancestry.parentHeadRefName}\`.`,
|
|
35
|
+
"Run `gh stack rebase --upstack --no-trunk`, resolve any conflicts, and push the rewritten stack with `gh stack push`.",
|
|
36
|
+
];
|
|
37
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
|
|
2
3
|
import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
|
|
3
4
|
/** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
|
|
@@ -5,135 +6,214 @@ export function withPollSummaryInstructions(result, mergeRequested) {
|
|
|
5
6
|
if (result.selection.kind !== "stack") {
|
|
6
7
|
return { ...result, instructions: explicitInstructions(result) };
|
|
7
8
|
}
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
const prs = [...result.prs].sort((left, right) => position(left) - position(right));
|
|
10
|
+
const staleChildren = new Set(result.stackAncestry?.map((gap) => gap.childPr) ?? []);
|
|
11
|
+
const firstUnready = prs.find((item) => item.state === "OPEN" && (!isReady(item) || staleChildren.has(item.pr)));
|
|
12
|
+
const blocked = prs.map((item) => firstUnready && item.state === "OPEN" && position(item) > position(firstUnready)
|
|
13
|
+
? {
|
|
14
|
+
...item,
|
|
15
|
+
...(["cancel", "mark_ready", "merge"].includes(item.action) && {
|
|
16
|
+
action: "wait",
|
|
17
|
+
reasons: [...item.reasons, "lower-layer-not-ready"],
|
|
18
|
+
}),
|
|
19
|
+
blockedByPr: firstUnready.pr,
|
|
20
|
+
...(item.isDraft &&
|
|
21
|
+
item.pollCommand && {
|
|
22
|
+
pollCommand: item.pollCommand.replace(" --until-terminal", " --timeout 1s --debounce 0s") +
|
|
23
|
+
(item.pollCommand.includes("--no-auto-mark-ready") ? "" : " --no-auto-mark-ready"),
|
|
24
|
+
}),
|
|
25
|
+
}
|
|
26
|
+
: item.state === "OPEN" &&
|
|
27
|
+
(!isReady(item) || staleChildren.has(item.pr)) &&
|
|
28
|
+
["cancel", "merge"].includes(item.action)
|
|
29
|
+
? {
|
|
30
|
+
...item,
|
|
31
|
+
action: "fix_code",
|
|
32
|
+
reasons: [
|
|
33
|
+
...item.reasons,
|
|
34
|
+
staleChildren.has(item.pr) ? "stale-ancestry" : "ready-receipt-required",
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
: item);
|
|
38
|
+
const projected = {
|
|
39
|
+
...result,
|
|
40
|
+
prs: blocked.map((item) => {
|
|
41
|
+
if (item.state === "OPEN" && item.isInMergeQueue && item.action === "cancel") {
|
|
42
|
+
return {
|
|
43
|
+
...item,
|
|
44
|
+
action: "wait",
|
|
45
|
+
reasons: [...item.reasons, "already-in-merge-queue"],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (item.state === "CLOSED" && closedDependency(blocked, item.pr)) {
|
|
49
|
+
return {
|
|
50
|
+
...item,
|
|
51
|
+
action: "escalate",
|
|
52
|
+
reasons: [...item.reasons, "closed-unmerged-dependency"],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (item.state !== "OPEN" && item.state !== "MERGED") {
|
|
56
|
+
return {
|
|
57
|
+
...item,
|
|
58
|
+
action: "escalate",
|
|
59
|
+
reasons: [...item.reasons, "unverified-stack-state"],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return item;
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
const planned = planStack(projected, mergeRequested);
|
|
16
66
|
const instructions = [...planned.instructions];
|
|
17
|
-
if (result.quotaWarning && planned.action
|
|
67
|
+
if (result.quotaWarning && planned.action === "shepherd") {
|
|
18
68
|
instructions.push(buildQuotaAwareContinuation(result.quotaWarning, `${instructions.length + 1}. After completing the stack action,`));
|
|
19
69
|
}
|
|
20
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
...projected,
|
|
72
|
+
reason: planned.action === "cancel"
|
|
73
|
+
? "all_terminal"
|
|
74
|
+
: planned.waiting
|
|
75
|
+
? result.reason === "timeout"
|
|
76
|
+
? "timeout"
|
|
77
|
+
: "waiting"
|
|
78
|
+
: "actionable",
|
|
79
|
+
stackMergeable: planned.stackMergeable,
|
|
80
|
+
nextAction: planned.action,
|
|
81
|
+
instructions,
|
|
82
|
+
};
|
|
21
83
|
}
|
|
22
84
|
function planStack(result, mergeRequested) {
|
|
23
85
|
const open = result.prs.filter((item) => item.state === "OPEN");
|
|
24
|
-
|
|
25
|
-
|
|
86
|
+
const lastOpen = open.at(-1);
|
|
87
|
+
const closedDependencyPr = result.prs.find((item) => item.state === "CLOSED" && lastOpen && position(item) < position(lastOpen));
|
|
88
|
+
const unverifiedLayer = result.prs.find((item) => item.state !== "OPEN" && item.state !== "MERGED");
|
|
89
|
+
const gaps = result.stackAncestry ?? [];
|
|
90
|
+
const stackMergeable = gaps.length === 0 && open.every(isReady);
|
|
91
|
+
const candidates = open.filter((item) => !isReady(item) || gaps.some((gap) => gap.childPr === item.pr));
|
|
92
|
+
const autonomousCandidates = candidates.filter((item) => item.action !== "escalate");
|
|
93
|
+
const runnableCandidates = autonomousCandidates.filter((item) => item.pollCommand);
|
|
94
|
+
const missingCommands = autonomousCandidates.filter((item) => !item.pollCommand);
|
|
95
|
+
const escalated = result.prs.filter((item) => item.action === "escalate");
|
|
96
|
+
if (closedDependencyPr || unverifiedLayer || escalated.length > 0) {
|
|
97
|
+
const instructions = [];
|
|
98
|
+
appendAutonomousInstructions(instructions, runnableCandidates);
|
|
99
|
+
const stop = runnableCandidates.length === 0;
|
|
100
|
+
if (closedDependencyPr) {
|
|
101
|
+
instructions.push(`${instructions.length + 1}. PR #${closedDependencyPr.pr} was closed without merging below an open layer. ${stop ? "Stop and ask" : "After autonomous shepherding, ask"} the stack owner whether to restore that dependency or rebuild the upper branches.`);
|
|
102
|
+
}
|
|
103
|
+
if (unverifiedLayer && unverifiedLayer.pr !== closedDependencyPr?.pr) {
|
|
104
|
+
instructions.push(`${instructions.length + 1}. PR #${unverifiedLayer.pr} has state \`${unverifiedLayer.state}\` rather than open or merged. ${stop ? "Stop and ask" : "After autonomous shepherding, ask"} the stack owner to reconcile this layer before declaring the stack complete.`);
|
|
105
|
+
}
|
|
106
|
+
for (const item of escalated) {
|
|
107
|
+
if (item.pr === closedDependencyPr?.pr || item.pr === unverifiedLayer?.pr)
|
|
108
|
+
continue;
|
|
109
|
+
instructions.push(`${instructions.length + 1}. PR #${item.pr} requires human action (${item.reasons.join(", ")}). ${stop ? "Stop for that decision." : "Keep shepherding other PRs before the handoff."}`);
|
|
110
|
+
}
|
|
111
|
+
for (const item of missingCommands) {
|
|
112
|
+
instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. Ask for direction.`);
|
|
113
|
+
}
|
|
114
|
+
if (runnableCandidates.length > 0) {
|
|
115
|
+
instructions.push(`${instructions.length + 1}. After the listed one-PR sessions, rerun this same \`--stack\` selector. Stop for the human handoff only when no autonomous shepherding remains.`);
|
|
116
|
+
}
|
|
117
|
+
return { action: stop ? "escalate" : "shepherd", stackMergeable: false, instructions };
|
|
26
118
|
}
|
|
27
|
-
|
|
28
|
-
const closedBelowOpen = result.prs.find((item) => item.state === "CLOSED" && positionOf(result, item.pr) < lastOpenPosition);
|
|
29
|
-
if (closedBelowOpen) {
|
|
119
|
+
if (open.length === 0) {
|
|
30
120
|
return {
|
|
31
|
-
action: "
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"2. Ask the stack owner which recovery path to take, then rerun the same aggregate `--stack` selector after the stack is repaired.",
|
|
35
|
-
],
|
|
121
|
+
action: "cancel",
|
|
122
|
+
stackMergeable: true,
|
|
123
|
+
instructions: ["1. Stop — every stack layer is merged."],
|
|
36
124
|
};
|
|
37
125
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
126
|
+
if (!stackMergeable) {
|
|
127
|
+
if (missingCommands.length > 0) {
|
|
128
|
+
const instructions = [];
|
|
129
|
+
appendAutonomousInstructions(instructions, runnableCandidates);
|
|
130
|
+
for (const item of missingCommands) {
|
|
131
|
+
instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. ${runnableCandidates.length === 0 ? "Stop and ask" : "After autonomous shepherding, ask"} for direction.`);
|
|
132
|
+
}
|
|
133
|
+
if (runnableCandidates.length > 0) {
|
|
134
|
+
instructions.push(`${instructions.length + 1}. After the listed one-PR sessions, rerun this same \`--stack\` selector. Stop for the human handoff only when no autonomous shepherding remains.`);
|
|
135
|
+
}
|
|
46
136
|
return {
|
|
47
|
-
action: "
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
],
|
|
137
|
+
action: runnableCandidates.length > 0 ? "shepherd" : "escalate",
|
|
138
|
+
stackMergeable: false,
|
|
139
|
+
instructions,
|
|
52
140
|
};
|
|
53
141
|
}
|
|
54
|
-
|
|
55
|
-
|
|
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);
|
|
142
|
+
const instructions = [];
|
|
143
|
+
appendAutonomousInstructions(instructions, autonomousCandidates);
|
|
144
|
+
instructions.push(`${instructions.length + 1}. After the selected one-PR sessions, rerun this same \`--stack\` selector.`);
|
|
145
|
+
return { action: "shepherd", stackMergeable: false, instructions };
|
|
61
146
|
}
|
|
62
|
-
if (
|
|
147
|
+
if (open.some((item) => item.isInMergeQueue)) {
|
|
63
148
|
return {
|
|
64
|
-
action: "
|
|
149
|
+
action: "wait",
|
|
150
|
+
stackMergeable: true,
|
|
151
|
+
waiting: true,
|
|
65
152
|
instructions: [
|
|
66
|
-
|
|
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.",
|
|
153
|
+
"1. The stack is in the merge queue. Recheck at the configured polling cadence; finish only after every layer is merged, and route any ejected layer to its one-PR session.",
|
|
69
154
|
],
|
|
70
155
|
};
|
|
71
156
|
}
|
|
72
|
-
|
|
73
|
-
if (behind)
|
|
74
|
-
return rebaseWholeStack(result, behind);
|
|
75
|
-
if (firstWork)
|
|
76
|
-
return pollOneLayer(firstWork);
|
|
77
|
-
if (!mergeRequested && open.every((item) => item.action === "cancel")) {
|
|
157
|
+
if (!mergeRequested && result.prs.every((item) => item.action === "cancel")) {
|
|
78
158
|
return {
|
|
79
159
|
action: "cancel",
|
|
80
|
-
|
|
160
|
+
stackMergeable: true,
|
|
161
|
+
instructions: ["1. Stop — every stack layer is terminal or fully READY."],
|
|
81
162
|
};
|
|
82
163
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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) {
|
|
164
|
+
if (!mergeRequested) {
|
|
165
|
+
const instructions = [];
|
|
166
|
+
appendAutonomousInstructions(instructions, open.filter((item) => item.action !== "cancel"));
|
|
167
|
+
instructions.push(`${instructions.length + 1}. After the selected one-PR sessions, rerun this same \`--stack\` selector.`);
|
|
110
168
|
return {
|
|
111
|
-
action: "
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
],
|
|
169
|
+
action: "shepherd",
|
|
170
|
+
stackMergeable,
|
|
171
|
+
instructions,
|
|
115
172
|
};
|
|
116
173
|
}
|
|
174
|
+
const stackNumber = result.selection.kind === "stack" ? result.selection.stackNumber : 0;
|
|
117
175
|
return {
|
|
118
|
-
action:
|
|
176
|
+
action: "merge",
|
|
177
|
+
stackMergeable: true,
|
|
119
178
|
instructions: [
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"3.
|
|
179
|
+
"1. Check `gh stack merge --help`. If the `gh-stack` extension is unavailable, run `gh extension install github/gh-stack`, then rerun this same `--stack --merge` selector before merging.",
|
|
180
|
+
`2. Stack #${stackNumber} in \`${result.repo}\` is mergeable through PR #${open.at(-1).pr}. Run \`GH_REPO=${result.repo} gh stack merge --yes --squash ${stackNumber}\` to merge the whole native stack or enqueue it when the base uses a merge queue.`,
|
|
181
|
+
"3. After the merge attempt, rerun this same `--stack --merge` selector until every layer is merged (`CANCEL`); shepherd any layer that GitHub rejects or ejects.",
|
|
123
182
|
],
|
|
124
183
|
};
|
|
125
184
|
}
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
185
|
+
function closedDependency(items, pr) {
|
|
186
|
+
const open = items.filter((item) => item.state === "OPEN");
|
|
187
|
+
const lastOpen = open.at(-1);
|
|
188
|
+
return Boolean(lastOpen &&
|
|
189
|
+
items.some((item) => item.pr === pr && item.state === "CLOSED" && position(item) < position(lastOpen)));
|
|
190
|
+
}
|
|
191
|
+
function appendAutonomousInstructions(instructions, candidates) {
|
|
192
|
+
if (candidates.length === 0)
|
|
193
|
+
return;
|
|
194
|
+
instructions.push(`${instructions.length + 1}. Start or delegate the relevant one-PR sessions below; review and CI work on separate layers can proceed concurrently.`);
|
|
195
|
+
for (const item of candidates) {
|
|
196
|
+
instructions.push(item.pollCommand
|
|
197
|
+
? `${instructions.length + 1}. Run \`${item.pollCommand}\` for PR #${item.pr}${item.blockedByPr ? ` (stack-blocked by PR #${item.blockedByPr})` : ""}${item.queueRemoval ? `; GitHub removed it from the merge queue (${item.queueRemoval.reason ?? "unknown reason"})` : ""}.`
|
|
198
|
+
: `${instructions.length + 1}. PR #${item.pr} needs a one-PR Shepherd session, but no command was available.`);
|
|
134
199
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
200
|
+
instructions.push(`${instructions.length + 1}. Keep upper draft PRs in draft until every lower layer has completed Shepherd READY.`);
|
|
201
|
+
}
|
|
202
|
+
function isReady(item) {
|
|
203
|
+
return (item.state === "OPEN" &&
|
|
204
|
+
item.stack !== undefined &&
|
|
205
|
+
item.readyReceipt === true &&
|
|
206
|
+
!item.isDraft &&
|
|
207
|
+
!item.queueRemoval &&
|
|
208
|
+
item.mergeable !== "CONFLICTING" &&
|
|
209
|
+
item.mergeStateStatus !== "DIRTY" &&
|
|
210
|
+
(item.isInMergeQueue || item.mergeable === "MERGEABLE") &&
|
|
211
|
+
(item.isInMergeQueue ||
|
|
212
|
+
!["DIRTY", "BEHIND", "UNKNOWN", "BLOCKED", "HAS_HOOKS"].includes(item.mergeStateStatus)) &&
|
|
213
|
+
(item.checks?.failing ?? 0) === 0 &&
|
|
214
|
+
(item.isInMergeQueue || (item.checks?.inProgress ?? 0) === 0) &&
|
|
215
|
+
(item.review?.actionable ?? 0) === 0);
|
|
216
|
+
}
|
|
217
|
+
function position(item) {
|
|
218
|
+
return item.stack?.position ?? Number.MAX_SAFE_INTEGER;
|
|
139
219
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
import { loadConfig } from "../config/load.mjs";
|
|
2
3
|
import { ShepherdError } from "../exit-codes.mjs";
|
|
3
4
|
import { getRepoInfo } from "../github/client.mjs";
|
|
@@ -54,16 +55,31 @@ async function runAggregatePollCore(opts) {
|
|
|
54
55
|
stackPrNumber: undefined,
|
|
55
56
|
prNumbers: last.prs.map((item) => item.pr),
|
|
56
57
|
});
|
|
57
|
-
if (explicit.prs.every((item) => item.
|
|
58
|
+
if (explicit.prs.every((item) => item.state === "MERGED")) {
|
|
58
59
|
const warning = await aggregateQuotaWarning(explicit, quotaBands, opts.intervalSeconds);
|
|
59
60
|
if (warning)
|
|
60
61
|
pendingQuotaWarning = warning;
|
|
61
62
|
return attachUsage({
|
|
62
63
|
...explicit,
|
|
64
|
+
selection: last.selection,
|
|
63
65
|
reason: "all_terminal",
|
|
66
|
+
nextAction: "cancel",
|
|
67
|
+
stackMergeable: true,
|
|
68
|
+
instructions: ["1. Stop — every stack layer is merged."],
|
|
64
69
|
...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
|
|
65
70
|
}, opts.merge);
|
|
66
71
|
}
|
|
72
|
+
return attachUsage({
|
|
73
|
+
...explicit,
|
|
74
|
+
selection: last.selection,
|
|
75
|
+
reason: "actionable",
|
|
76
|
+
nextAction: "escalate",
|
|
77
|
+
stackMergeable: false,
|
|
78
|
+
instructions: [
|
|
79
|
+
`1. Native stack #${last.selection.stackNumber} disappeared before every tracked layer merged. Stop and ask the stack owner to reconcile the remaining PRs; do not claim the stack is complete.`,
|
|
80
|
+
],
|
|
81
|
+
...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
|
|
82
|
+
}, opts.merge);
|
|
67
83
|
}
|
|
68
84
|
const retryMs = opts.untilTerminal ? pollGraphQlRetryAfterMs(error) : null;
|
|
69
85
|
if (retryMs === null || rateLimitRetries >= 1)
|
|
@@ -77,10 +93,11 @@ async function runAggregatePollCore(opts) {
|
|
|
77
93
|
? last.nextAction === "cancel"
|
|
78
94
|
: last.prs.every((item) => item.action === "cancel");
|
|
79
95
|
const immediate = last.selection.kind === "stack"
|
|
80
|
-
?
|
|
96
|
+
? (last.nextAction === "escalate" || last.nextAction === "merge") &&
|
|
97
|
+
last.reason !== "waiting"
|
|
81
98
|
: last.prs.some((item) => ["escalate", "merge", "mark_ready"].includes(item.action));
|
|
82
99
|
const hasFix = last.selection.kind === "stack"
|
|
83
|
-
? last.nextAction === "
|
|
100
|
+
? last.nextAction === "shepherd" && last.reason !== "waiting"
|
|
84
101
|
: last.prs.some((item) => item.action === "fix_code");
|
|
85
102
|
const warning = await aggregateQuotaWarning(last, quotaBands, opts.intervalSeconds);
|
|
86
103
|
if (warning)
|
|
@@ -145,5 +162,12 @@ function isMissingStack(error) {
|
|
|
145
162
|
}
|
|
146
163
|
function attachUsage(result, mergeRequested) {
|
|
147
164
|
const apiUsage = summarizeApiTelemetry();
|
|
148
|
-
|
|
165
|
+
const enriched = apiUsage ? { ...result, apiUsage } : result;
|
|
166
|
+
// A disappeared native stack is an aggregate-level terminal handoff. Keep
|
|
167
|
+
// that explicit escalation intact even when the one-PR fallback contains
|
|
168
|
+
// open orphan rows that would otherwise be re-planned as fix_code.
|
|
169
|
+
if (enriched.selection.kind === "stack" && enriched.nextAction === "escalate") {
|
|
170
|
+
return enriched;
|
|
171
|
+
}
|
|
172
|
+
return withPollSummaryInstructions(enriched, mergeRequested === true);
|
|
149
173
|
}
|
package/bin/exit-codes.d.mts
CHANGED
|
@@ -25,6 +25,8 @@ export declare const EXIT: Readonly<{
|
|
|
25
25
|
readonly CLOSED: 14;
|
|
26
26
|
/** Agent should run the emitted merge or queue command. */
|
|
27
27
|
readonly MERGE: 15;
|
|
28
|
+
/** Aggregate stack has PRs to shepherd through one-PR sessions. */
|
|
29
|
+
readonly SHEPHERD: 16;
|
|
28
30
|
/** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
|
|
29
31
|
readonly USAGE: 64;
|
|
30
32
|
/** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
|
package/bin/exit-codes.mjs
CHANGED
|
@@ -24,6 +24,8 @@ export const EXIT = Object.freeze({
|
|
|
24
24
|
CLOSED: 14,
|
|
25
25
|
/** Agent should run the emitted merge or queue command. */
|
|
26
26
|
MERGE: 15,
|
|
27
|
+
/** Aggregate stack has PRs to shepherd through one-PR sessions. */
|
|
28
|
+
SHEPHERD: 16,
|
|
27
29
|
/** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
|
|
28
30
|
USAGE: 64,
|
|
29
31
|
/** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
|
|
@@ -125,6 +125,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
125
125
|
null,
|
|
126
126
|
},
|
|
127
127
|
baseRefName: raw.baseRefName,
|
|
128
|
+
...(raw.baseRef?.target?.oid && { baseRefOid: raw.baseRef.target.oid }),
|
|
128
129
|
reviewRequests,
|
|
129
130
|
latestReviews,
|
|
130
131
|
reviewThreads,
|
|
@@ -3,6 +3,7 @@ fragment PollSummaryPr on PullRequest {
|
|
|
3
3
|
title
|
|
4
4
|
url
|
|
5
5
|
state
|
|
6
|
+
updatedAt
|
|
6
7
|
isDraft
|
|
7
8
|
viewerCanUpdate
|
|
8
9
|
headRefName
|
|
@@ -11,6 +12,26 @@ fragment PollSummaryPr on PullRequest {
|
|
|
11
12
|
baseRefOid
|
|
12
13
|
mergeable
|
|
13
14
|
mergeStateStatus
|
|
15
|
+
lifecycleEvents: timelineItems(
|
|
16
|
+
last: 1
|
|
17
|
+
itemTypes: [CLOSED_EVENT, REOPENED_EVENT, CONVERT_TO_DRAFT_EVENT, READY_FOR_REVIEW_EVENT]
|
|
18
|
+
) {
|
|
19
|
+
nodes {
|
|
20
|
+
__typename
|
|
21
|
+
... on ClosedEvent {
|
|
22
|
+
createdAt
|
|
23
|
+
}
|
|
24
|
+
... on ReopenedEvent {
|
|
25
|
+
createdAt
|
|
26
|
+
}
|
|
27
|
+
... on ConvertToDraftEvent {
|
|
28
|
+
createdAt
|
|
29
|
+
}
|
|
30
|
+
... on ReadyForReviewEvent {
|
|
31
|
+
createdAt
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
14
35
|
reviewDecision
|
|
15
36
|
reviewRequests(last: 50) {
|
|
16
37
|
nodes {
|
|
@@ -38,6 +59,33 @@ fragment PollSummaryPr on PullRequest {
|
|
|
38
59
|
}
|
|
39
60
|
}
|
|
40
61
|
isInMergeQueue
|
|
62
|
+
mergeQueueAdditions: timelineItems(last: 1, itemTypes: [ADDED_TO_MERGE_QUEUE_EVENT]) {
|
|
63
|
+
nodes {
|
|
64
|
+
... on AddedToMergeQueueEvent {
|
|
65
|
+
createdAt
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
mergeQueueRemovals: timelineItems(last: 1, itemTypes: [REMOVED_FROM_MERGE_QUEUE_EVENT]) {
|
|
70
|
+
nodes {
|
|
71
|
+
... on RemovedFromMergeQueueEvent {
|
|
72
|
+
id
|
|
73
|
+
reason
|
|
74
|
+
createdAt
|
|
75
|
+
actor {
|
|
76
|
+
login
|
|
77
|
+
}
|
|
78
|
+
beforeCommit {
|
|
79
|
+
oid
|
|
80
|
+
parents(first: 100) {
|
|
81
|
+
nodes {
|
|
82
|
+
oid
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
41
89
|
mergeQueueEntry {
|
|
42
90
|
headCommit {
|
|
43
91
|
statusCheckRollup {
|
|
@@ -54,6 +102,9 @@ fragment PollSummaryPr on PullRequest {
|
|
|
54
102
|
status
|
|
55
103
|
conclusion
|
|
56
104
|
detailsUrl
|
|
105
|
+
annotations(first: 1) {
|
|
106
|
+
totalCount
|
|
107
|
+
}
|
|
57
108
|
checkSuite {
|
|
58
109
|
workflowRun {
|
|
59
110
|
databaseId
|
|
@@ -175,6 +226,9 @@ fragment PollSummaryPr on PullRequest {
|
|
|
175
226
|
status
|
|
176
227
|
conclusion
|
|
177
228
|
detailsUrl
|
|
229
|
+
annotations(first: 1) {
|
|
230
|
+
totalCount
|
|
231
|
+
}
|
|
178
232
|
checkSuite {
|
|
179
233
|
workflowRun {
|
|
180
234
|
databaseId
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
2
|
+
/**
|
|
3
|
+
* Bind receipts to the review and head-commit CI evidence shared by one-PR
|
|
4
|
+
* completion and stack reconciliation. Queue entry, queue checks, and GitHub's
|
|
5
|
+
* computed merge state are checked separately: including them here would
|
|
6
|
+
* invalidate a READY receipt merely because a ready PR entered the queue.
|
|
7
|
+
*/
|
|
8
|
+
export declare function fingerprintRawSummaryPr(raw: RawSummaryPr): string | null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Bind receipts to the review and head-commit CI evidence shared by one-PR
|
|
4
|
+
* completion and stack reconciliation. Queue entry, queue checks, and GitHub's
|
|
5
|
+
* computed merge state are checked separately: including them here would
|
|
6
|
+
* invalidate a READY receipt merely because a ready PR entered the queue.
|
|
7
|
+
*/
|
|
8
|
+
export function fingerprintRawSummaryPr(raw) {
|
|
9
|
+
if (!raw.updatedAt || !raw.headRefOid || !raw.baseRefOid)
|
|
10
|
+
return null;
|
|
11
|
+
const evidence = {
|
|
12
|
+
number: raw.number,
|
|
13
|
+
state: raw.state,
|
|
14
|
+
lifecycleEvents: raw.lifecycleEvents,
|
|
15
|
+
isDraft: raw.isDraft,
|
|
16
|
+
headRefOid: raw.headRefOid,
|
|
17
|
+
baseRefOid: raw.baseRefOid,
|
|
18
|
+
baseRefName: raw.baseRefName,
|
|
19
|
+
reviewDecision: raw.reviewDecision,
|
|
20
|
+
reviewRequests: raw.reviewRequests,
|
|
21
|
+
latestReviews: raw.latestReviews,
|
|
22
|
+
comments: raw.comments,
|
|
23
|
+
reviews: raw.reviews,
|
|
24
|
+
reviewThreads: raw.reviewThreads,
|
|
25
|
+
commits: raw.commits,
|
|
26
|
+
};
|
|
27
|
+
return createHash("sha256").update(JSON.stringify(evidence)).digest("hex");
|
|
28
|
+
}
|