pr-shepherd 0.51.1 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -18
- package/bin/cli/help-command-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +4 -3
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +3 -2
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/iterate-instructions.mjs +41 -0
- package/bin/cli/iterate-lean.mjs +1 -3
- package/bin/cli/poll-summary-emitter.mjs +2 -3
- package/bin/cli/poll-summary-formatter.mjs +12 -3
- package/bin/cli/runner.d.mts +1 -0
- package/bin/cli/runner.mjs +3 -1
- package/bin/commands/check.mjs +14 -4
- package/bin/commands/clean.mjs +7 -13
- package/bin/commands/iterate/check-instructions.d.mts +2 -2
- package/bin/commands/iterate/check-instructions.mjs +11 -7
- package/bin/commands/iterate/escalate.mjs +4 -13
- package/bin/commands/iterate/fix-code.d.mts +2 -0
- package/bin/commands/iterate/fix-code.mjs +20 -43
- package/bin/commands/iterate/helpers.d.mts +0 -1
- package/bin/commands/iterate/helpers.mjs +0 -15
- package/bin/commands/iterate/index.mjs +178 -11
- package/bin/commands/iterate/merge-state.mjs +35 -18
- package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
- package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
- package/bin/commands/iterate/parent-first.d.mts +25 -0
- package/bin/commands/iterate/parent-first.mjs +70 -0
- package/bin/commands/iterate/render.d.mts +1 -1
- package/bin/commands/iterate/render.mjs +7 -12
- package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
- package/bin/commands/iterate/stale-ancestry.mjs +40 -0
- package/bin/commands/iterate/stall.mjs +40 -3
- package/bin/commands/poll-progress.d.mts +5 -1
- package/bin/commands/poll-progress.mjs +7 -1
- package/bin/commands/poll-summary-instructions.d.mts +6 -1
- package/bin/commands/poll-summary-instructions.mjs +151 -107
- package/bin/commands/poll-summary.mjs +32 -6
- package/bin/commands/poll.mjs +3 -1
- package/bin/commands/ready-delay.d.mts +9 -4
- package/bin/commands/ready-delay.mjs +34 -20
- package/bin/commands/shepherd-journal.mjs +4 -1
- package/bin/commands/stack-drain.d.mts +35 -0
- package/bin/commands/stack-drain.mjs +129 -0
- package/bin/commands/stack-layer-readiness.d.mts +7 -0
- package/bin/commands/stack-layer-readiness.mjs +35 -0
- package/bin/commands/stack-stall.d.mts +14 -0
- package/bin/commands/stack-stall.mjs +69 -0
- package/bin/commands/stack-work.d.mts +32 -0
- package/bin/commands/stack-work.mjs +39 -0
- package/bin/config/load.d.mts +2 -0
- package/bin/config/load.mjs +10 -0
- package/bin/config.json +1 -0
- 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-types.d.mts +2 -0
- package/bin/github/errors.d.mts +5 -0
- package/bin/github/errors.mjs +4 -0
- package/bin/github/gql/batch-pr.gql +1 -0
- package/bin/github/gql/poll-stack-summary.gql +8 -2
- package/bin/github/gql/poll-stack-topology.gql +38 -0
- package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
- package/bin/github/gql/poll-summary-check-page.gql +23 -0
- package/bin/github/gql/poll-summary-fragment.gql +53 -56
- package/bin/github/merge-queue-checks.mjs +10 -1
- package/bin/github/poll-summary-check-hydration.d.mts +12 -0
- package/bin/github/poll-summary-check-hydration.mjs +55 -0
- package/bin/github/poll-summary-fingerprint.d.mts +8 -0
- package/bin/github/poll-summary-fingerprint.mjs +48 -0
- package/bin/github/poll-summary-projector.mjs +70 -16
- package/bin/github/poll-summary-queue-removal.d.mts +5 -0
- package/bin/github/poll-summary-queue-removal.mjs +25 -0
- package/bin/github/poll-summary-raw.d.mts +52 -31
- package/bin/github/poll-summary-readiness.d.mts +6 -0
- package/bin/github/poll-summary-readiness.mjs +25 -0
- package/bin/github/poll-summary-route.mjs +8 -5
- package/bin/github/poll-summary.d.mts +3 -0
- package/bin/github/poll-summary.mjs +21 -73
- package/bin/github/queries.d.mts +7 -0
- package/bin/github/queries.mjs +9 -1
- package/bin/github/queue-removal-freshness.d.mts +16 -0
- package/bin/github/queue-removal-freshness.mjs +26 -0
- package/bin/github/stack-read.d.mts +34 -0
- package/bin/github/stack-read.mjs +92 -0
- package/bin/log/log-file.d.mts +1 -1
- package/bin/log/log-file.mjs +4 -17
- package/bin/state/base.d.mts +18 -1
- package/bin/state/base.mjs +65 -13
- package/bin/state/fix-attempts.d.mts +1 -1
- package/bin/state/fix-attempts.mjs +1 -1
- package/bin/state/graphql-quota-warnings.mjs +2 -6
- package/bin/state/iterate-stall.d.mts +7 -15
- package/bin/state/iterate-stall.mjs +6 -64
- package/bin/state/ready-receipts.d.mts +45 -0
- package/bin/state/ready-receipts.mjs +86 -0
- package/bin/state/rest-cache.d.mts +1 -1
- package/bin/state/rest-cache.mjs +1 -1
- package/bin/state/stack-stall.d.mts +16 -0
- package/bin/state/stack-stall.mjs +12 -0
- package/bin/state/stall-state-store.d.mts +37 -0
- package/bin/state/stall-state-store.mjs +74 -0
- package/bin/types/escalate.d.mts +2 -3
- package/bin/types/github.d.mts +2 -0
- package/bin/types/iterate.d.mts +2 -1
- package/bin/types/merge-requirements.d.mts +19 -0
- package/bin/types/poll-summary.d.mts +17 -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
|
@@ -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
|
}
|
|
@@ -249,6 +240,10 @@ export function buildEscalateSuggestion(triggers, detail) {
|
|
|
249
240
|
const duration = detail ?? "60 minutes";
|
|
250
241
|
return `No progress detected for ${duration} — state has not changed. This is a manual checkpoint: inspect the PR and apply a manual fix before resuming.`;
|
|
251
242
|
}
|
|
243
|
+
if (triggers.includes("stall-state-unavailable")) {
|
|
244
|
+
const reason = detail ?? "unknown error";
|
|
245
|
+
return `The stall timer could not be saved (${reason}). Fix PR_SHEPHERD_STATE_DIR or the directory permissions, then resume. Automated polling is paused because a stuck loop would not be detected.`;
|
|
246
|
+
}
|
|
252
247
|
if (triggers.includes("base-branch-unknown")) {
|
|
253
248
|
const reason = detail ? ` (${detail})` : "";
|
|
254
249
|
return `Could not determine the PR's base branch${reason} — automated rebases are paused because branch safety is unclear. Run the rebase manually against the PR's real target branch.`;
|
|
@@ -257,9 +252,5 @@ export function buildEscalateSuggestion(triggers, detail) {
|
|
|
257
252
|
const attempts = loadConfig().iterate.fixAttemptsPerThread;
|
|
258
253
|
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
254
|
}
|
|
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
255
|
return "Ambiguous state — automated handling cannot proceed safely. Inspect the PR and act manually.";
|
|
265
256
|
}
|
|
@@ -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,12 +1,12 @@
|
|
|
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";
|
|
7
6
|
import { buildResolveCommand } from "./classify.mjs";
|
|
8
7
|
import { buildThreadMutationRouting, threadHasAuthorizedMutation, } from "./thread-mutation-routing.mjs";
|
|
9
8
|
import { buildFixInstructions } from "./render.mjs";
|
|
9
|
+
import { buildNativeStackLayerRebase } from "./native-stack-rebase.mjs";
|
|
10
10
|
import { applyStallGuard } from "./stall.mjs";
|
|
11
11
|
import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
|
|
12
12
|
import { threadTranscriptBody } from "../../threads/transcript.mjs";
|
|
@@ -17,15 +17,15 @@ import { formatPrUrl } from "../../pr-reference.mjs";
|
|
|
17
17
|
function checkRequiresHumanFollowUp(check) {
|
|
18
18
|
if (check.rerunCommand)
|
|
19
19
|
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
20
|
if (check.conclusion === "ACTION_REQUIRED" ||
|
|
26
21
|
check.conclusion === "CANCELLED" ||
|
|
27
22
|
check.conclusion === "STARTUP_FAILURE")
|
|
28
23
|
return true;
|
|
24
|
+
// A later attempt cannot be rerun automatically, but its included log can still
|
|
25
|
+
// identify a code or configuration fix for the agent. Only a later failure with
|
|
26
|
+
// no actionable evidence needs a human handoff.
|
|
27
|
+
if (check.runAttempt !== undefined && check.runAttempt > 1)
|
|
28
|
+
return !check.logExcerpt?.trim();
|
|
29
29
|
// An external check's direct URL is actionable evidence: the agent can inspect the
|
|
30
30
|
// provider and/or reproduce the reported failure locally. Only a truly bare check
|
|
31
31
|
// has no autonomous investigation path.
|
|
@@ -68,7 +68,7 @@ function pendingReviewCommands(resolveCommand, resolveOnlyCommand) {
|
|
|
68
68
|
return Object.keys(pending).length > 0 ? pending : undefined;
|
|
69
69
|
}
|
|
70
70
|
export async function handleFixCode(ctx) {
|
|
71
|
-
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, } = ctx;
|
|
71
|
+
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
|
|
72
72
|
const prReference = formatPrUrl(report.repo, prNumber);
|
|
73
73
|
const failingChecks = report.checks.failing;
|
|
74
74
|
const annotatedExtra = checksWithActionableAnnotations(report).filter((c) => c.category !== "failing");
|
|
@@ -105,37 +105,6 @@ export async function handleFixCode(ctx) {
|
|
|
105
105
|
...(report.comments.minimizeIds ?? report.comments.actionable.map((comment) => comment.id)),
|
|
106
106
|
...reviewSummaryIds,
|
|
107
107
|
], 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
108
|
const escalateTriggers = countFixCodeAttempt
|
|
140
109
|
? checkEscalateTriggers(retryableActionableThreads, priorThreadAttempts)
|
|
141
110
|
: { triggers: [], thrashHistory: undefined };
|
|
@@ -216,8 +185,9 @@ export async function handleFixCode(ctx) {
|
|
|
216
185
|
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
217
186
|
const belongsToActiveWorkflowRun = (check) => check.runId !== null && inProgressWorkflowRunIds.has(check.runId);
|
|
218
187
|
const manualFollowUpChecks = failingAgentChecks.filter((check) => !belongsToActiveWorkflowRun(check) && checkRequiresHumanFollowUp(check));
|
|
219
|
-
const exhaustedAttempts =
|
|
220
|
-
const
|
|
188
|
+
const exhaustedAttempts = failingAgentChecks.filter((check) => check.runAttempt !== undefined && check.runAttempt > 1);
|
|
189
|
+
const manualExhaustedAttempts = exhaustedAttempts.filter((check) => manualFollowUpChecks.includes(check));
|
|
190
|
+
const hasBehindBaseRecovery = isBehind && manualExhaustedAttempts.length > 0;
|
|
221
191
|
const hasAutonomousWork = hasConflicts ||
|
|
222
192
|
hasBehindBaseRecovery ||
|
|
223
193
|
threads.length > 0 ||
|
|
@@ -235,8 +205,8 @@ export async function handleFixCode(ctx) {
|
|
|
235
205
|
if (manualFollowUpChecks.length > 0 && !hasAutonomousWork) {
|
|
236
206
|
const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(failingAgentChecks);
|
|
237
207
|
const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
|
|
238
|
-
const checkSuggestion =
|
|
239
|
-
? `GitHub reports a later workflow attempt (${
|
|
208
|
+
const checkSuggestion = manualExhaustedAttempts.length > 0
|
|
209
|
+
? `GitHub reports a later workflow attempt (${manualExhaustedAttempts
|
|
240
210
|
.map((check) => `${check.runId ?? check.name}: attempt ${check.runAttempt}`)
|
|
241
211
|
.join(", ")}), so Shepherd's single rerun allowance is exhausted. Use the included evidence to handle the repeated failure manually before resuming.`
|
|
242
212
|
: buildEscalateSuggestion(["check-follow-up-unavailable"]);
|
|
@@ -302,7 +272,14 @@ export async function handleFixCode(ctx) {
|
|
|
302
272
|
}
|
|
303
273
|
const firstLookThreads = report.threads.firstLook;
|
|
304
274
|
const firstLookComments = report.comments.firstLook;
|
|
305
|
-
|
|
275
|
+
// Conflicts, and a behind branch whose rerun already failed, both ask for a branch update.
|
|
276
|
+
const stackRebase = hasConflicts || (isBehind && exhaustedAttempts.length > 0)
|
|
277
|
+
? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, report.mergeStatus.mergeRequirements?.stack)
|
|
278
|
+
: undefined;
|
|
279
|
+
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, stackRebase);
|
|
280
|
+
if (repairInstructions && repairInstructions.length > 0) {
|
|
281
|
+
instructions.unshift(...repairInstructions);
|
|
282
|
+
}
|
|
306
283
|
const prospectiveResult = {
|
|
307
284
|
...base,
|
|
308
285
|
baseBranch: baseLookup.branch,
|
|
@@ -7,6 +7,5 @@ export declare function buildTerminalCancelResult(report: ShepherdReport): Itera
|
|
|
7
7
|
/** Build completed, non-skipped checks relevant to PR readiness. */
|
|
8
8
|
export declare function buildRelevantChecks(report: ShepherdReport): RelevantCheck[];
|
|
9
9
|
export declare function buildActiveChecks(report: ShepherdReport): ActiveCheck[];
|
|
10
|
-
export declare function getCurrentHeadSha(): Promise<string | null>;
|
|
11
10
|
export declare function buildWaitLog(base: IterateResultBase): string;
|
|
12
11
|
export declare function blockedCancelNote(base: IterateResultBase): string;
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import { execFile as execFileCb } from "node:child_process";
|
|
2
|
-
import { promisify } from "node:util";
|
|
3
|
-
import { getExecutionCwd } from "../../execution-context.mjs";
|
|
4
1
|
import { blockedReasonFromRequirements } from "../../merge-status/requirements-format.mjs";
|
|
5
|
-
const execFile = promisify(execFileCb);
|
|
6
2
|
export function buildSummary(report) {
|
|
7
3
|
return {
|
|
8
4
|
passing: report.checks.passing.length,
|
|
@@ -104,17 +100,6 @@ export function buildActiveChecks(report) {
|
|
|
104
100
|
...(c.commitOid !== undefined && { commitOid: c.commitOid }),
|
|
105
101
|
}));
|
|
106
102
|
}
|
|
107
|
-
export async function getCurrentHeadSha() {
|
|
108
|
-
try {
|
|
109
|
-
const { stdout } = await execFile("git", ["rev-parse", "HEAD"], {
|
|
110
|
-
cwd: getExecutionCwd(),
|
|
111
|
-
});
|
|
112
|
-
return stdout.trim();
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
103
|
export function buildWaitLog(base) {
|
|
119
104
|
const { summary, remainingSeconds } = base;
|
|
120
105
|
const parts = [`WAIT: ${summary.passing} passing, ${summary.inProgress} in-progress`];
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
2
|
import { runCheck } from "../check.mjs";
|
|
3
|
-
import { updateReadyDelay } from "../ready-delay.mjs";
|
|
3
|
+
import { clearReadyDelay, updateReadyDelay } from "../ready-delay.mjs";
|
|
4
4
|
import { getCurrentPrNumber } from "../../github/client.mjs";
|
|
5
5
|
import { loadConfig } from "../../config/load.mjs";
|
|
6
6
|
import { EXIT, ShepherdError } from "../../exit-codes.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { buildWaitLog, buildTerminalCancelResult, blockedCancelNote } from "./helpers.mjs";
|
|
8
8
|
import { classifyReviewSummaries } from "./classify.mjs";
|
|
9
9
|
import { applyStallGuard } from "./stall.mjs";
|
|
10
10
|
import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
@@ -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 { findParentMarkReadyBlock, heldByLowerLayer, stackDraftHold } 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
|
}
|
|
@@ -43,8 +51,10 @@ async function runIterateCore(opts) {
|
|
|
43
51
|
throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
|
|
44
52
|
}
|
|
45
53
|
const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
54
|
+
const receiptKey = { owner: repoOwner, repo: repoName, pr: report.pr };
|
|
46
55
|
if (report.mergeStatus.state !== "OPEN") {
|
|
47
|
-
await
|
|
56
|
+
await clearReadyDelay(report.pr, repoOwner, repoName);
|
|
57
|
+
await clearReadyReceipt(receiptKey);
|
|
48
58
|
await clearStallState(stallKey);
|
|
49
59
|
return buildTerminalCancelResult(report);
|
|
50
60
|
}
|
|
@@ -66,24 +76,40 @@ async function runIterateCore(opts) {
|
|
|
66
76
|
if (unminimized.length > 0)
|
|
67
77
|
reviewSummaryIds = [...reviewSummaryIds, ...unminimized];
|
|
68
78
|
}
|
|
69
|
-
|
|
79
|
+
// Hidden PR comments surface once so the agent can acknowledge them, but
|
|
80
|
+
// they are not readiness evidence: bots keep editing hidden notices after a
|
|
81
|
+
// PR settles. They never restart the ready-delay or void a READY receipt.
|
|
82
|
+
const hasReadinessWork = report.threads.actionable.length > 0 ||
|
|
70
83
|
report.threads.resolutionOnly.length > 0 ||
|
|
71
84
|
report.threads.firstLook.length > 0 ||
|
|
72
85
|
(report.threads.ruleAutoResolveIds?.length ?? 0) > 0 ||
|
|
73
86
|
report.comments.actionable.length > 0 ||
|
|
74
87
|
(report.comments.minimizeIds?.length ?? 0) > 0 ||
|
|
75
|
-
report.comments.firstLook.length > 0 ||
|
|
76
88
|
report.changesRequestedReviews.length > 0 ||
|
|
77
89
|
hasCheckDrivenActionableWork(report.checks, report.mergeStatus.status) ||
|
|
78
90
|
reviewSummaryIds.length > 0 ||
|
|
79
91
|
firstLookSummaries.length > 0 ||
|
|
80
92
|
editedSummaries.length > 0 ||
|
|
81
93
|
(config.iterate.minimizeApprovals && surfacedApprovals.length > 0);
|
|
94
|
+
const hasActionableWork = hasReadinessWork || report.comments.firstLook.length > 0;
|
|
82
95
|
const activeMerge = Boolean(opts.merge && (report.mergeQueue?.inQueue || report.mergeQueue?.autoMergeRequest));
|
|
83
|
-
const
|
|
84
|
-
|
|
96
|
+
const staleAncestry = await findStaleNativeStackAncestry(report, {
|
|
97
|
+
owner: repoOwner,
|
|
98
|
+
name: repoName,
|
|
99
|
+
});
|
|
100
|
+
const isCleanReadyState = report.status === "READY" &&
|
|
101
|
+
!report.mergeStatus.isDraft &&
|
|
102
|
+
!hasReadinessWork &&
|
|
103
|
+
!activeMerge &&
|
|
104
|
+
staleAncestry === null;
|
|
105
|
+
const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork);
|
|
106
|
+
const headSha = report.headSha ?? "unknown";
|
|
107
|
+
// The elapsed marker survives a hidden-comment acknowledgement tick and is
|
|
108
|
+
// consumed only when this tick cancels or merges. A receipt that is still
|
|
109
|
+
// current already proves the delay elapsed for this exact head, base, and
|
|
110
|
+
// readiness evidence, so a rerun (e.g. with --merge) does not wait again.
|
|
111
|
+
const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName, { headSha, alreadyElapsed: receiptCurrent });
|
|
85
112
|
const base = buildIterateBase(report, readyState);
|
|
86
|
-
const headSha = (await getCurrentHeadSha()) ?? "unknown";
|
|
87
113
|
// Checks (including merge-queue synthetic-commit checks) and hard conflicts are signals
|
|
88
114
|
// GitHub itself is already acting on — the queue will eject the PR for these regardless of
|
|
89
115
|
// what Shepherd does, so they always surface immediately. Only review threads/comments/
|
|
@@ -126,13 +152,51 @@ async function runIterateCore(opts) {
|
|
|
126
152
|
});
|
|
127
153
|
if (mergeStateResult)
|
|
128
154
|
return mergeStateResult;
|
|
155
|
+
if (staleAncestry) {
|
|
156
|
+
return handleFixCode({
|
|
157
|
+
base,
|
|
158
|
+
report,
|
|
159
|
+
opts: { ...opts, prNumber, neverCancelRuns },
|
|
160
|
+
headSha,
|
|
161
|
+
stallKey,
|
|
162
|
+
prNumber,
|
|
163
|
+
stallTimeoutSeconds,
|
|
164
|
+
repoOwner,
|
|
165
|
+
repoName,
|
|
166
|
+
reviewSummaryIds,
|
|
167
|
+
firstLookSummaries,
|
|
168
|
+
editedSummaries,
|
|
169
|
+
surfacedApprovals,
|
|
170
|
+
botUsernames,
|
|
171
|
+
repairInstructions: staleAncestry.instructions,
|
|
172
|
+
ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
129
175
|
const canMarkReady = report.status === "READY" &&
|
|
130
176
|
report.mergeStatus.isDraft &&
|
|
131
177
|
!report.mergeStatus.blockingBotReviewInProgress;
|
|
132
|
-
const
|
|
178
|
+
const parentBlock = canMarkReady
|
|
179
|
+
? await findParentMarkReadyBlock(report, { owner: repoOwner, name: repoName })
|
|
180
|
+
: undefined;
|
|
181
|
+
const autoMarkReady = !opts.noAutoMarkReady && config.actions.autoMarkReady;
|
|
182
|
+
const markReadyResult = await markReadyIfAuthorized(canMarkReady && !parentBlock && autoMarkReady, base, report);
|
|
133
183
|
if (markReadyResult)
|
|
134
184
|
return markReadyResult;
|
|
135
|
-
if (readyState.shouldCancel) {
|
|
185
|
+
if (readyState.shouldCancel && !report.mergeStatus.isDraft) {
|
|
186
|
+
const receiptWritten = receiptCurrent || (await recordReadyReceipt(receiptKey, report));
|
|
187
|
+
// Aggregate --stack routing trusts only a layer's receipt, so an unwritten
|
|
188
|
+
// one keeps the elapsed marker and retries next tick. A one-PR receipt
|
|
189
|
+
// only lets a rerun skip the wait, so its failure never changes the action.
|
|
190
|
+
if (!receiptWritten && report.mergeStatus.mergeRequirements?.stack !== undefined) {
|
|
191
|
+
const receiptWait = {
|
|
192
|
+
...base,
|
|
193
|
+
action: "wait",
|
|
194
|
+
shouldCancel: false,
|
|
195
|
+
log: `WAIT: PR #${base.pr} reached ready-delay but its stack readiness receipt could not be persisted`,
|
|
196
|
+
};
|
|
197
|
+
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, receiptWait, report, reviewSummaryIds);
|
|
198
|
+
}
|
|
199
|
+
await clearReadyDelay(report.pr, repoOwner, repoName);
|
|
136
200
|
await clearStallState(stallKey);
|
|
137
201
|
const mergeResult = buildReadyMergeOutcome(opts.merge, true, base, report);
|
|
138
202
|
if (mergeResult)
|
|
@@ -145,5 +209,108 @@ async function runIterateCore(opts) {
|
|
|
145
209
|
log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
|
|
146
210
|
};
|
|
147
211
|
}
|
|
148
|
-
|
|
212
|
+
const hold = stackDraftHold(report, autoMarkReady, parentBlock);
|
|
213
|
+
const wait = {
|
|
214
|
+
...base,
|
|
215
|
+
action: "wait",
|
|
216
|
+
log: buildWaitLog(base),
|
|
217
|
+
...(hold && { stackDraftHold: hold }),
|
|
218
|
+
};
|
|
219
|
+
// The lower layer's own session owns progress here, so this draft cannot
|
|
220
|
+
// stall; its stall clock restarts once that layer releases it.
|
|
221
|
+
if (heldByLowerLayer(wait)) {
|
|
222
|
+
await clearStallState(stallKey);
|
|
223
|
+
return wait;
|
|
224
|
+
}
|
|
225
|
+
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, report, reviewSummaryIds);
|
|
226
|
+
}
|
|
227
|
+
async function recordReadyReceipt(key, report) {
|
|
228
|
+
if (report.status !== "READY" ||
|
|
229
|
+
report.mergeStatus.state !== "OPEN" ||
|
|
230
|
+
report.mergeStatus.isDraft ||
|
|
231
|
+
!report.headSha ||
|
|
232
|
+
!report.baseRefOid)
|
|
233
|
+
return false;
|
|
234
|
+
try {
|
|
235
|
+
const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
|
|
236
|
+
const fingerprint = fingerprintRawSummaryPr(raw);
|
|
237
|
+
if (fingerprint === null ||
|
|
238
|
+
raw.state !== "OPEN" ||
|
|
239
|
+
raw.isDraft ||
|
|
240
|
+
raw.headRefOid !== report.headSha ||
|
|
241
|
+
raw.baseRefOid !== report.baseRefOid)
|
|
242
|
+
return false;
|
|
243
|
+
const summary = await summarizePollSummaryPr(raw, { owner: key.owner, name: key.repo }, { stackPrNumber: report.pr });
|
|
244
|
+
if (!isCurrentSummaryReady(raw, summary.checks ?? {}, summary.review ?? {}))
|
|
245
|
+
return false;
|
|
246
|
+
const removalEvent = currentQueueRemovalEvent(raw);
|
|
247
|
+
await writeReadyReceipt({
|
|
248
|
+
version: 1,
|
|
249
|
+
...key,
|
|
250
|
+
headRefOid: raw.headRefOid,
|
|
251
|
+
baseRefOid: raw.baseRefOid,
|
|
252
|
+
status: "READY",
|
|
253
|
+
isDraft: false,
|
|
254
|
+
readinessFingerprint: fingerprint,
|
|
255
|
+
...(removalEvent?.id && {
|
|
256
|
+
acknowledgedQueueRemovalId: removalEvent.id,
|
|
257
|
+
}),
|
|
258
|
+
recordedAtUnix: Math.floor(Date.now() / 1000),
|
|
259
|
+
});
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// The receipt is supplementary evidence. A transient summary fetch or
|
|
264
|
+
// state-directory failure must not turn a completed one-PR poll into a
|
|
265
|
+
// different Shepherd action.
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Clear a stale READY receipt; return whether a current receipt remains. */
|
|
270
|
+
async function revalidateReadyReceipt(key, report, hasReadinessWork) {
|
|
271
|
+
const receipt = await readReadyReceipt(key);
|
|
272
|
+
if (!receipt)
|
|
273
|
+
return false;
|
|
274
|
+
const retainQueuedReceipt = report.mergeQueue?.inQueue === true &&
|
|
275
|
+
report.mergeStatus.state === "OPEN" &&
|
|
276
|
+
!report.mergeStatus.isDraft &&
|
|
277
|
+
Boolean(report.headSha && report.baseRefOid);
|
|
278
|
+
if (report.mergeStatus.state !== "OPEN" ||
|
|
279
|
+
report.mergeStatus.isDraft ||
|
|
280
|
+
!report.headSha ||
|
|
281
|
+
!report.baseRefOid ||
|
|
282
|
+
(!retainQueuedReceipt && (report.status !== "READY" || hasReadinessWork))) {
|
|
283
|
+
await clearReadyReceipt(key);
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
|
|
288
|
+
// Queue predecessors can advance the target branch without changing this
|
|
289
|
+
// PR's source. Compare against the receipt's base only while both fresh
|
|
290
|
+
// views still place the PR in the queue.
|
|
291
|
+
const queuedBaseAdvanced = retainQueuedReceipt && raw.isInMergeQueue;
|
|
292
|
+
const fingerprint = fingerprintRawSummaryPr(queuedBaseAdvanced ? { ...raw, baseRefOid: receipt.baseRefOid } : raw);
|
|
293
|
+
// The fresh snapshot must name the commits this tick evaluated, or the
|
|
294
|
+
// receipt would vouch for a head or base the report never saw.
|
|
295
|
+
const sameCommits = raw.headRefOid === report.headSha &&
|
|
296
|
+
(queuedBaseAdvanced || raw.baseRefOid === report.baseRefOid);
|
|
297
|
+
if (fingerprint === null ||
|
|
298
|
+
!sameCommits ||
|
|
299
|
+
!isReadyReceiptCurrent(receipt, {
|
|
300
|
+
headRefOid: raw.headRefOid,
|
|
301
|
+
baseRefOid: queuedBaseAdvanced ? receipt.baseRefOid : raw.baseRefOid,
|
|
302
|
+
readinessFingerprint: fingerprint,
|
|
303
|
+
status: "READY",
|
|
304
|
+
isDraft: raw.isDraft,
|
|
305
|
+
})) {
|
|
306
|
+
await clearReadyReceipt(key);
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// Fail closed: an unreadable current snapshot cannot validate old evidence.
|
|
313
|
+
await clearReadyReceipt(key);
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
149
316
|
}
|
|
@@ -2,26 +2,43 @@ 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
|
-
|
|
12
|
-
|
|
13
|
-
stack,
|
|
14
|
-
suggestion: buildEscalateSuggestion(["stacked-pr"], String(report.pr)),
|
|
15
|
-
};
|
|
5
|
+
import { buildPrShepherdCommand } from "../../cli/runner.mjs";
|
|
6
|
+
import { inlineCode } from "../../util/markdown.mjs";
|
|
7
|
+
/**
|
|
8
|
+
* A one-PR poll cannot prove the layers below it merged or that its PR number is a safe merge
|
|
9
|
+
* selector. Route it through the aggregate stack selector, which merges one bottom layer at a time.
|
|
10
|
+
*/
|
|
11
|
+
function buildStackedRouteResult(base, report, stack) {
|
|
12
|
+
const prUrl = formatPrUrl(report.repo, report.pr);
|
|
16
13
|
return {
|
|
17
14
|
...base,
|
|
18
|
-
action: "
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
action: "fix_code",
|
|
16
|
+
fix: {
|
|
17
|
+
threads: [],
|
|
18
|
+
resolutionOnlyThreads: [],
|
|
19
|
+
actionableComments: [],
|
|
20
|
+
reviewSummaryIds: [],
|
|
21
|
+
firstLookSummaries: [],
|
|
22
|
+
editedSummaries: [],
|
|
23
|
+
surfacedApprovals: [],
|
|
24
|
+
checks: [],
|
|
25
|
+
changesRequestedReviews: [],
|
|
26
|
+
resolveCommand: {
|
|
27
|
+
argv: buildPrShepherdCommand(["apply", "review", prUrl]).argv,
|
|
28
|
+
requiresHeadSha: false,
|
|
29
|
+
requiresDismissMessage: false,
|
|
30
|
+
hasMutations: false,
|
|
31
|
+
},
|
|
32
|
+
instructions: [
|
|
33
|
+
`PR #${report.pr} is layer ${stack.position} of ${stack.size} in native stack #${stack.number}; do not run \`gh pr merge\` for this layer.`,
|
|
34
|
+
`Run ${inlineCode(buildPrShepherdCommand(["--stack", prUrl, "--until-terminal", "--merge"]).text)} to reconcile the stack and run each bottom-layer merge command it emits.`,
|
|
35
|
+
],
|
|
36
|
+
inProgressRunIds: [],
|
|
37
|
+
protectedRuns: [],
|
|
38
|
+
firstLookThreads: [],
|
|
39
|
+
firstLookComments: [],
|
|
24
40
|
},
|
|
41
|
+
cancelled: [],
|
|
25
42
|
};
|
|
26
43
|
}
|
|
27
44
|
export function buildReadyMergeOutcome(enabled, readyElapsed, base, report) {
|
|
@@ -29,7 +46,7 @@ export function buildReadyMergeOutcome(enabled, readyElapsed, base, report) {
|
|
|
29
46
|
return null;
|
|
30
47
|
const stack = report.mergeStatus.mergeRequirements?.stack;
|
|
31
48
|
if (stack)
|
|
32
|
-
return
|
|
49
|
+
return buildStackedRouteResult(base, report, stack);
|
|
33
50
|
const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
|
|
34
51
|
report.mergeStatus.mergeRequirements?.mergeQueue?.enabled);
|
|
35
52
|
return {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { StackStatus } from "../../types.mts";
|
|
2
|
+
/**
|
|
3
|
+
* Where a native-stack rebase starts: an upper layer rebases from its parent stack branch
|
|
4
|
+
* without touching trunk; the bottom layer rebases the whole stack onto trunk.
|
|
5
|
+
*/
|
|
6
|
+
export type NativeStackRebaseStart = {
|
|
7
|
+
parentBranch: string;
|
|
8
|
+
} | {
|
|
9
|
+
bottomPr: number;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* One gh-stack rebase step. A native stack layer must not be rebased or merged from its base
|
|
13
|
+
* branch alone: that rewrites one branch and strands every layer above it.
|
|
14
|
+
*
|
|
15
|
+
* `gh stack` rebases the stack it tracks locally and `gh stack push` publishes those local
|
|
16
|
+
* layers, so the step first imports the stack by its number (a bare number resolves as a
|
|
17
|
+
* stack number before a PR number) and checks each local layer against its PR head.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildNativeStackRebaseInstruction(repo: string, stackNumber: number, start: NativeStackRebaseStart): string;
|
|
20
|
+
/**
|
|
21
|
+
* The stack-aware branch update for a native stack layer (a conflict, or a behind branch whose
|
|
22
|
+
* workflow keeps failing); undefined outside a stack.
|
|
23
|
+
* A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
|
|
24
|
+
* position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
|
|
25
|
+
* layer is an upper layer whose parent is its own PR base branch.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildNativeStackLayerRebase(repo: string, pr: {
|
|
28
|
+
number: number;
|
|
29
|
+
baseBranch: string;
|
|
30
|
+
}, stack: StackStatus | undefined): string | undefined;
|
|
31
|
+
/** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
|
|
32
|
+
export declare function buildConflictInstruction(stackRebase: string | undefined): string;
|
|
33
|
+
/** Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack. */
|
|
34
|
+
export declare function buildBranchPushInstruction(stackRebase: string | undefined, hasConflicts: boolean, mutationSuffix: string): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One gh-stack rebase step. A native stack layer must not be rebased or merged from its base
|
|
3
|
+
* branch alone: that rewrites one branch and strands every layer above it.
|
|
4
|
+
*
|
|
5
|
+
* `gh stack` rebases the stack it tracks locally and `gh stack push` publishes those local
|
|
6
|
+
* layers, so the step first imports the stack by its number (a bare number resolves as a
|
|
7
|
+
* stack number before a PR number) and checks each local layer against its PR head.
|
|
8
|
+
*/
|
|
9
|
+
export function buildNativeStackRebaseInstruction(repo, stackNumber, start) {
|
|
10
|
+
const [checkout, command] = "parentBranch" in start
|
|
11
|
+
? [
|
|
12
|
+
`check out the parent stack branch \`${start.parentBranch}\``,
|
|
13
|
+
"gh stack rebase --upstack --no-trunk",
|
|
14
|
+
]
|
|
15
|
+
: [`check out the head branch of PR #${start.bottomPr}`, "gh stack rebase"];
|
|
16
|
+
const prepare = `if \`gh stack\` does not track stack #${stackNumber} locally, import it with \`gh stack checkout ${stackNumber}\`, then confirm every layer's local branch is at its PR's head commit — a stale local layer would overwrite that PR's newer commits on push`;
|
|
17
|
+
return `From a clean checkout of \`${repo}\`, ${prepare}. Then ${checkout} and run \`${command}\`; if it stops on a conflict, resolve it and run \`gh stack rebase --continue\`.`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The stack-aware branch update for a native stack layer (a conflict, or a behind branch whose
|
|
21
|
+
* workflow keeps failing); undefined outside a stack.
|
|
22
|
+
* A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
|
|
23
|
+
* position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
|
|
24
|
+
* layer is an upper layer whose parent is its own PR base branch.
|
|
25
|
+
*/
|
|
26
|
+
export function buildNativeStackLayerRebase(repo, pr, stack) {
|
|
27
|
+
if (!stack)
|
|
28
|
+
return undefined;
|
|
29
|
+
return buildNativeStackRebaseInstruction(repo, stack.number, pr.baseBranch === stack.baseRefName ? { bottomPr: pr.number } : { parentBranch: pr.baseBranch });
|
|
30
|
+
}
|
|
31
|
+
/** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
|
|
32
|
+
export function buildConflictInstruction(stackRebase) {
|
|
33
|
+
const pointer = "The branch has merge conflicts (see `**branch**` above).";
|
|
34
|
+
return stackRebase ? `${pointer} ${stackRebase}` : `${pointer} Resolve them before committing.`;
|
|
35
|
+
}
|
|
36
|
+
/** Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack. */
|
|
37
|
+
export function buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix) {
|
|
38
|
+
if (stackRebase)
|
|
39
|
+
return `Commit any remaining changes on the PR head branch and push the rewritten stack with \`gh stack push\`${mutationSuffix}.`;
|
|
40
|
+
return hasConflicts
|
|
41
|
+
? `Commit any remaining conflict-resolution changes and push to the PR head branch${mutationSuffix}.`
|
|
42
|
+
: "Push the updated PR head branch before iterating immediately.";
|
|
43
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RepoInfo } from "../../github/client.mts";
|
|
2
|
+
import type { IterateResult, ShepherdReport, StackDraftHold, StackLowerLayerBlock } from "../../types.mts";
|
|
3
|
+
/**
|
|
4
|
+
* What keeps a draft child from being marked ready: the lowest blocking lower layer,
|
|
5
|
+
* or a stack read that could not attribute the block to one.
|
|
6
|
+
*/
|
|
7
|
+
export type ParentMarkReadyBlock = StackLowerLayerBlock | "unverifiable";
|
|
8
|
+
/**
|
|
9
|
+
* Draft children may only be converted after their immediate parent has
|
|
10
|
+
* independently completed a one-PR ready-delay and the stack boundary is
|
|
11
|
+
* still linear. A failed or incomplete stack read blocks this mutation but
|
|
12
|
+
* does not block ordinary review/CI work in the caller.
|
|
13
|
+
*/
|
|
14
|
+
export declare function findParentMarkReadyBlock(report: ShepherdReport, repo: RepoInfo): Promise<ParentMarkReadyBlock | undefined>;
|
|
15
|
+
/**
|
|
16
|
+
* A native stack draft this one-PR session cannot promote: a lower layer blocks it, or
|
|
17
|
+
* automatic mark-ready is off. Undefined when the session can still advance the PR by
|
|
18
|
+
* iterating.
|
|
19
|
+
*/
|
|
20
|
+
export declare function stackDraftHold(report: ShepherdReport, autoMarkReady: boolean, parentBlock: ParentMarkReadyBlock | undefined): StackDraftHold | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* The lower layer a held draft waits on. That layer's own session owns this draft's
|
|
23
|
+
* progress, so the draft neither stalls nor keeps polling while it waits.
|
|
24
|
+
*/
|
|
25
|
+
export declare function heldByLowerLayer(result: IterateResult): StackLowerLayerBlock | undefined;
|