pr-shepherd 0.56.0 → 0.56.2
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 +7 -4
- package/bin/api.d.mts +11 -1
- package/bin/api.mjs +25 -0
- package/bin/cli/args.mjs +3 -0
- package/bin/cli/fix-formatter.mjs +3 -0
- package/bin/cli/help-command-pages.d.mts +17 -2
- package/bin/cli/help-command-pages.mjs +16 -1
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +1 -1
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +2 -0
- package/bin/cli/help.d.mts +18 -3
- package/bin/cli/help.mjs +2 -0
- package/bin/cli/iterate-instructions.mjs +4 -1
- package/bin/cli/iterate-lean.mjs +3 -0
- package/bin/cli/iterate-merge-formatter.mjs +2 -0
- package/bin/cli/queue-removal-handler.d.mts +2 -0
- package/bin/cli/queue-removal-handler.mjs +86 -0
- package/bin/cli-parser.mjs +4 -0
- package/bin/commands/apply-queue-removal.d.mts +18 -0
- package/bin/commands/apply-queue-removal.mjs +44 -0
- package/bin/commands/check-fingerprint.mjs +2 -0
- package/bin/commands/check.mjs +13 -1
- package/bin/commands/iterate/check-evidence.d.mts +4 -0
- package/bin/commands/iterate/check-evidence.mjs +8 -0
- package/bin/commands/iterate/fix-code.mjs +20 -4
- package/bin/commands/iterate/index.mjs +15 -1
- package/bin/commands/iterate/merge-state.mjs +4 -1
- package/bin/commands/iterate/merge.d.mts +7 -1
- package/bin/commands/iterate/merge.mjs +59 -1
- package/bin/github/poll-summary-fingerprint.mjs +7 -0
- package/bin/github/poll-summary-mergeability.d.mts +11 -0
- package/bin/github/poll-summary-mergeability.mjs +23 -0
- package/bin/github/poll-summary-readiness.mjs +11 -1
- package/bin/github/poll-summary.mjs +7 -2
- package/bin/mcp/server.mjs +18 -0
- package/bin/state/queue-removal-ack.d.mts +19 -0
- package/bin/state/queue-removal-ack.mjs +66 -0
- package/bin/types/iterate.d.mts +4 -0
- package/bin/types/merge-queue.d.mts +2 -0
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -2
- package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +1 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
2
|
+
import { fetchPrBatch } from "../github/batch.mjs";
|
|
3
|
+
import { queueRemovalAppliesToHead } from "../github/queue-removal-freshness.mjs";
|
|
4
|
+
import { getCurrentPrNumber, getRepoInfo } from "../github/client.mjs";
|
|
5
|
+
import { isCiQueueRemovalReason, writeQueueRemovalAcknowledgment, } from "../state/queue-removal-ack.mjs";
|
|
6
|
+
/** Validate an observed native-stack CI queue removal before recording the caller's acknowledgment. */
|
|
7
|
+
export async function applyQueueRemovalAck(input) {
|
|
8
|
+
const repo = input.targetRepository ?? (await getRepoInfo());
|
|
9
|
+
const pr = input.prNumber ?? (await getCurrentPrNumber());
|
|
10
|
+
if (pr === null) {
|
|
11
|
+
throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
|
|
12
|
+
}
|
|
13
|
+
const { data } = await fetchPrBatch(pr, repo);
|
|
14
|
+
const removal = data.latestMergeQueueRemoval;
|
|
15
|
+
if (data.state !== "OPEN" ||
|
|
16
|
+
!data.stack ||
|
|
17
|
+
data.isMergeQueueEnabled !== true ||
|
|
18
|
+
data.isInMergeQueue === true ||
|
|
19
|
+
!removal ||
|
|
20
|
+
!isCiQueueRemovalReason(removal.reason) ||
|
|
21
|
+
data.headRefOid !== input.headSha ||
|
|
22
|
+
removal.beforeCommitOid !== input.queueCommitOid ||
|
|
23
|
+
removal.createdAtUnix !== input.removedAtUnix ||
|
|
24
|
+
!queueRemovalAppliesToHead({
|
|
25
|
+
parentOids: removal.beforeCommitParentOids,
|
|
26
|
+
headOid: data.headRefOid,
|
|
27
|
+
...(data.activity?.latestCommitCommittedAtUnix != null && {
|
|
28
|
+
headCommittedAtUnix: data.activity.latestCommitCommittedAtUnix,
|
|
29
|
+
}),
|
|
30
|
+
...(data.headPushedAtUnix !== undefined && { headPushedAtUnix: data.headPushedAtUnix }),
|
|
31
|
+
removedAtUnix: removal.createdAtUnix,
|
|
32
|
+
})) {
|
|
33
|
+
throw new ShepherdError("The supplied queue-removal evidence is stale or is not a current CI-driven removal for this native-stack PR.", EXIT.UNAVAILABLE);
|
|
34
|
+
}
|
|
35
|
+
const acknowledgment = {
|
|
36
|
+
headSha: input.headSha,
|
|
37
|
+
queueCommitOid: input.queueCommitOid,
|
|
38
|
+
removedAtUnix: input.removedAtUnix,
|
|
39
|
+
};
|
|
40
|
+
if (!(await writeQueueRemovalAcknowledgment({ owner: repo.owner, repo: repo.name, pr }, acknowledgment))) {
|
|
41
|
+
throw new ShepherdError("Could not write queue-removal acknowledgment state.", EXIT.UNAVAILABLE);
|
|
42
|
+
}
|
|
43
|
+
return { pr, repo: `${repo.owner}/${repo.name}`, acknowledgment };
|
|
44
|
+
}
|
|
@@ -10,6 +10,8 @@ function reportAllowsFingerprintSkip(report) {
|
|
|
10
10
|
return false;
|
|
11
11
|
if (report.mergeQueue?.inQueue === true)
|
|
12
12
|
return false;
|
|
13
|
+
if (report.mergeQueue?.removalAcknowledged === true)
|
|
14
|
+
return false;
|
|
13
15
|
return (report.threads.actionable.length === 0 &&
|
|
14
16
|
report.threads.resolutionOnly.length === 0 &&
|
|
15
17
|
report.threads.firstLook.length === 0 &&
|
package/bin/commands/check.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
2
2
|
import { queueRemovalAppliesToHead } from "../github/queue-removal-freshness.mjs";
|
|
3
|
+
import { readQueueRemovalAcknowledgment, matchesQueueRemovalAcknowledgment, isCiQueueRemovalReason, } from "../state/queue-removal-ack.mjs";
|
|
3
4
|
import { storePrFingerprint } from "../state/pr-fingerprint.mjs";
|
|
4
5
|
import { tryReuseFingerprintReport } from "./check-fingerprint.mjs";
|
|
5
6
|
import { collectUnreportedRequired, refreshCachedUnreported } from "./check-unreported.mjs";
|
|
@@ -91,9 +92,19 @@ export async function runCheck(opts, context) {
|
|
|
91
92
|
}),
|
|
92
93
|
removedAtUnix: latestRemoval.createdAtUnix,
|
|
93
94
|
}));
|
|
95
|
+
const removalAcknowledged = Boolean(batchData.stack &&
|
|
96
|
+
latestRemoval?.beforeCommitOid &&
|
|
97
|
+
isCiQueueRemovalReason(latestRemoval.reason) &&
|
|
98
|
+
!batchData.isInMergeQueue &&
|
|
99
|
+
!headUpdatedAfterRemoval &&
|
|
100
|
+
matchesQueueRemovalAcknowledgment(await readQueueRemovalAcknowledgment(stateKey), {
|
|
101
|
+
headSha: batchData.headRefOid,
|
|
102
|
+
queueCommitOid: latestRemoval.beforeCommitOid,
|
|
103
|
+
removedAtUnix: latestRemoval.createdAtUnix,
|
|
104
|
+
}));
|
|
94
105
|
const queueRawChecks = batchData.isInMergeQueue
|
|
95
106
|
? (batchData.mergeQueueChecks ?? [])
|
|
96
|
-
: latestRemoval && !headUpdatedAfterRemoval
|
|
107
|
+
: latestRemoval && !headUpdatedAfterRemoval && !removalAcknowledged
|
|
97
108
|
? (batchData.removedMergeQueueChecks ?? [])
|
|
98
109
|
: [];
|
|
99
110
|
// Keep supersession grouping commit-local, but accept merge_group only for the
|
|
@@ -356,6 +367,7 @@ export async function runCheck(opts, context) {
|
|
|
356
367
|
checksIncomplete: true,
|
|
357
368
|
}),
|
|
358
369
|
...(headUpdatedAfterRemoval && { headUpdatedAfterRemoval: true }),
|
|
370
|
+
...(removalAcknowledged && { removalAcknowledged: true }),
|
|
359
371
|
},
|
|
360
372
|
}),
|
|
361
373
|
...(unreported.unreportedRequiredChecks && {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AgentCheck } from "../../types.mts";
|
|
2
|
+
export declare function hasLogEvidence(check: AgentCheck): boolean;
|
|
3
|
+
/** An external provider link is inspectable even when Actions log downloads are unavailable. */
|
|
4
|
+
export declare function hasQueueRecoveryEvidence(check: AgentCheck): boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function hasLogEvidence(check) {
|
|
2
|
+
return (Boolean(check.logExcerpt?.trim()) ||
|
|
3
|
+
(check.relatedJobs ?? []).some((job) => Boolean(job.logExcerpt?.trim())));
|
|
4
|
+
}
|
|
5
|
+
/** An external provider link is inspectable even when Actions log downloads are unavailable. */
|
|
6
|
+
export function hasQueueRecoveryEvidence(check) {
|
|
7
|
+
return hasLogEvidence(check) || (check.runId === null && Boolean(check.detailsUrl?.trim()));
|
|
8
|
+
}
|
|
@@ -6,6 +6,8 @@ import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, bui
|
|
|
6
6
|
import { buildResolveCommand } from "./classify.mjs";
|
|
7
7
|
import { buildThreadMutationRouting, threadHasAuthorizedMutation, } from "./thread-mutation-routing.mjs";
|
|
8
8
|
import { buildFixInstructions } from "./render.mjs";
|
|
9
|
+
import { buildRemovedQueueRecovery, buildStackQueueRemovalAcknowledgment } from "./merge.mjs";
|
|
10
|
+
import { hasLogEvidence } from "./check-evidence.mjs";
|
|
9
11
|
import { buildReleasedBlockerInstruction } from "./check-instructions.mjs";
|
|
10
12
|
import { buildNativeStackLayerRebase } from "./native-stack-rebase.mjs";
|
|
11
13
|
import { lookupUpperLayerTrunkConflict } from "./stack-trunk-conflict.mjs";
|
|
@@ -19,10 +21,6 @@ import { canRerunWorkflows } from "../../checks/conclusions.mjs";
|
|
|
19
21
|
import { loadConfig } from "../../config/load.mjs";
|
|
20
22
|
import { formatPrUrl } from "../../pr-reference.mjs";
|
|
21
23
|
const EMPTY_RELEASED = new Set();
|
|
22
|
-
function hasLogEvidence(check) {
|
|
23
|
-
return (Boolean(check.logExcerpt?.trim()) ||
|
|
24
|
-
(check.relatedJobs ?? []).some((job) => Boolean(job.logExcerpt?.trim())));
|
|
25
|
-
}
|
|
26
24
|
function checkRequiresHumanFollowUp(check) {
|
|
27
25
|
if (check.rerunCommand)
|
|
28
26
|
return false;
|
|
@@ -171,6 +169,8 @@ export async function handleFixCode(ctx) {
|
|
|
171
169
|
if (releasedCheckNames.has(c.name))
|
|
172
170
|
return c;
|
|
173
171
|
return rerunAuthorized &&
|
|
172
|
+
// Rerunning queue CI cannot restore a removed entry and overwrites its failure evidence.
|
|
173
|
+
c.scope !== "merge_group" &&
|
|
174
174
|
c.runId &&
|
|
175
175
|
actionsRunIds.has(c.runId) &&
|
|
176
176
|
// GitHub increments run_attempt after every rerun. Recommend at most one rerun by limiting
|
|
@@ -306,6 +306,20 @@ export async function handleFixCode(ctx) {
|
|
|
306
306
|
? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, stack, trunkConflict)
|
|
307
307
|
: undefined;
|
|
308
308
|
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);
|
|
309
|
+
const requeue = buildRemovedQueueRecovery(report, failingAgentChecks, opts.merge);
|
|
310
|
+
const queueRemovalAcknowledgment = buildStackQueueRemovalAcknowledgment(report, failingAgentChecks);
|
|
311
|
+
if (queueRemovalAcknowledgment) {
|
|
312
|
+
const completion = instructions.pop();
|
|
313
|
+
instructions.push("If the merge-group failure belongs to this PR, fix and push its head, then iterate. Otherwise, if no code changed and no other blocker remains, run `acknowledge queue removal:` exactly as printed. This records only the disposition of that removed queue commit; finish this one-PR session to validate current source CI and record its READY receipt, then return to the aggregate `--stack` selector with its original options. In merge mode it verifies lower-layer readiness before merging. Do not enqueue or merge this layer directly.");
|
|
314
|
+
if (completion !== undefined)
|
|
315
|
+
instructions.push(completion);
|
|
316
|
+
}
|
|
317
|
+
if (requeue) {
|
|
318
|
+
const completion = instructions.pop();
|
|
319
|
+
instructions.push("If the merge-group failure belongs to this PR, fix and push the PR head, then iterate. Otherwise, if no code changed and no other blocker remains, run the `requeue:` command exactly as printed. If gh reports auto-merge is disabled instead of adding the PR to the queue, run the `requeue API fallback:` command. Both commands require the observed PR head SHA; if the head changed, iterate for a fresh command.");
|
|
320
|
+
if (completion !== undefined)
|
|
321
|
+
instructions.push(completion);
|
|
322
|
+
}
|
|
309
323
|
if (failingAgentChecks.some((check) => releasedCheckNames.has(check.name))) {
|
|
310
324
|
const completion = instructions.pop();
|
|
311
325
|
instructions.push(buildReleasedBlockerInstruction(prNumber));
|
|
@@ -342,6 +356,8 @@ export async function handleFixCode(ctx) {
|
|
|
342
356
|
editedSummaries,
|
|
343
357
|
surfacedApprovals,
|
|
344
358
|
checks,
|
|
359
|
+
...(requeue && { requeue }),
|
|
360
|
+
...(queueRemovalAcknowledgment && { queueRemovalAcknowledgment }),
|
|
345
361
|
changesRequestedReviews,
|
|
346
362
|
resolveCommand,
|
|
347
363
|
...(resolveOnlyCommand !== undefined ? { resolveOnlyCommand } : undefined),
|
|
@@ -109,7 +109,9 @@ async function runIterateCore(opts) {
|
|
|
109
109
|
editedSummaries.length > 0 ||
|
|
110
110
|
(config.iterate.minimizeApprovals && surfacedApprovals.length > 0);
|
|
111
111
|
const hasActionableWork = hasReadinessWork || report.comments.firstLook.length > 0;
|
|
112
|
-
const activeMerge = Boolean(opts.merge &&
|
|
112
|
+
const activeMerge = Boolean(opts.merge &&
|
|
113
|
+
(report.mergeQueue?.inQueue ||
|
|
114
|
+
(report.mergeQueue?.autoMergeRequest && !report.mergeQueue.removalAcknowledged)));
|
|
113
115
|
const staleAncestry = await findStaleNativeStackAncestry(report, {
|
|
114
116
|
owner: repoOwner,
|
|
115
117
|
name: repoName,
|
|
@@ -307,6 +309,18 @@ async function recordReadyReceipt(key, report, context) {
|
|
|
307
309
|
if (!isCurrentSummaryReady(raw, summary.checks ?? {}, summary.review ?? {}))
|
|
308
310
|
return false;
|
|
309
311
|
const removalEvent = currentQueueRemovalEvent(raw);
|
|
312
|
+
// Bind recovery to the exact evidence the one-PR check accepted. A new ejection
|
|
313
|
+
// between that check and this fresh snapshot must get its own acknowledgment.
|
|
314
|
+
if (report.mergeQueue?.removalAcknowledged) {
|
|
315
|
+
const accepted = report.mergeQueue.latestRemoval;
|
|
316
|
+
if (raw.isInMergeQueue ||
|
|
317
|
+
!accepted ||
|
|
318
|
+
!removalEvent ||
|
|
319
|
+
removalEvent.beforeCommit?.oid !== accepted.beforeCommitOid ||
|
|
320
|
+
Math.floor(Date.parse(removalEvent.createdAt) / 1000) !== accepted.createdAtUnix ||
|
|
321
|
+
removalEvent.reason !== accepted.reason)
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
310
324
|
await writeReadyReceipt({
|
|
311
325
|
version: 1,
|
|
312
326
|
...key,
|
|
@@ -115,7 +115,10 @@ export async function handleActiveMergeState(input) {
|
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
const removal = report.mergeQueue?.latestRemoval;
|
|
118
|
-
if (!enabled ||
|
|
118
|
+
if (!enabled ||
|
|
119
|
+
!removal ||
|
|
120
|
+
report.mergeQueue?.headUpdatedAfterRemoval ||
|
|
121
|
+
report.mergeQueue?.removalAcknowledged)
|
|
119
122
|
return null;
|
|
120
123
|
await clearStallState(stallKey);
|
|
121
124
|
const escalateBase = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type MergeMethod } from "../../config/merge-method.mts";
|
|
2
|
-
import type { IterateResult, IterateResultBase, MergeCommandPlan } from "../../types.mts";
|
|
2
|
+
import type { AgentCheck, IterateResult, IterateResultBase, MergeCommandPlan, ShepherdReport } from "../../types.mts";
|
|
3
3
|
interface MergePlanInput {
|
|
4
4
|
pr: number;
|
|
5
5
|
repo: string;
|
|
@@ -16,6 +16,12 @@ export declare function buildMergeCommandPlan(input: MergePlanInput): MergeComma
|
|
|
16
16
|
export declare function renderMergeCommand(command: {
|
|
17
17
|
argv: string[];
|
|
18
18
|
}): string;
|
|
19
|
+
/** Offer a guarded queue command; the caller decides whether the failure warrants a PR fix. */
|
|
20
|
+
export declare function buildRemovedQueueRecovery(report: ShepherdReport, checks: AgentCheck[], merge: boolean | undefined): MergeCommandPlan | undefined;
|
|
21
|
+
/** Bind a caller's disposition to one removed stack queue commit, without submitting a merge. */
|
|
22
|
+
export declare function buildStackQueueRemovalAcknowledgment(report: ShepherdReport, checks: AgentCheck[]): {
|
|
23
|
+
argv: string[];
|
|
24
|
+
} | undefined;
|
|
19
25
|
export declare function unavailableMergeResult(base: IterateResultBase, report: {
|
|
20
26
|
pr: number;
|
|
21
27
|
repo: string;
|
|
@@ -2,7 +2,9 @@ import { loadConfig } from "../../config/load.mjs";
|
|
|
2
2
|
import { findMergeStrategies } from "../../config/merge-command-args.mjs";
|
|
3
3
|
import { chooseMergeMethod, configuredMergeMethod, } from "../../config/merge-method.mjs";
|
|
4
4
|
import { formatPrUrl } from "../../pr-reference.mjs";
|
|
5
|
-
import { renderShellCommand } from "../../cli/runner.mjs";
|
|
5
|
+
import { renderShellCommand, buildPrShepherdCommand } from "../../cli/runner.mjs";
|
|
6
|
+
import { hasQueueRecoveryEvidence } from "./check-evidence.mjs";
|
|
7
|
+
import { isCiQueueRemovalReason } from "../../state/queue-removal-ack.mjs";
|
|
6
8
|
import { buildEscalateHumanMessage } from "./escalate.mjs";
|
|
7
9
|
const ENQUEUE_MUTATION = "mutation EnqueuePullRequest($pullRequestId: ID!, $expectedHeadOid: GitObjectID!) { enqueuePullRequest(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid }) { mergeQueueEntry { id } } }";
|
|
8
10
|
export function buildMergeCommandPlan(input) {
|
|
@@ -55,6 +57,62 @@ export function buildMergeCommandPlan(input) {
|
|
|
55
57
|
export function renderMergeCommand(command) {
|
|
56
58
|
return renderShellCommand(command.argv);
|
|
57
59
|
}
|
|
60
|
+
/** Offer a guarded queue command; the caller decides whether the failure warrants a PR fix. */
|
|
61
|
+
export function buildRemovedQueueRecovery(report, checks, merge) {
|
|
62
|
+
if (report.mergeStatus.mergeRequirements?.stack)
|
|
63
|
+
return undefined;
|
|
64
|
+
if (!removedQueueRecoveryAvailable(report, checks, merge))
|
|
65
|
+
return undefined;
|
|
66
|
+
const plan = buildMergeCommandPlan({
|
|
67
|
+
pr: report.pr,
|
|
68
|
+
repo: report.repo,
|
|
69
|
+
nodeId: report.nodeId,
|
|
70
|
+
headSha: report.headSha,
|
|
71
|
+
queue: true,
|
|
72
|
+
});
|
|
73
|
+
return "unavailable" in plan ? undefined : plan;
|
|
74
|
+
}
|
|
75
|
+
/** Bind a caller's disposition to one removed stack queue commit, without submitting a merge. */
|
|
76
|
+
export function buildStackQueueRemovalAcknowledgment(report, checks) {
|
|
77
|
+
if (!report.mergeStatus.mergeRequirements?.stack)
|
|
78
|
+
return undefined;
|
|
79
|
+
// This records a local disposition, without enqueueing; aggregate child sessions omit --merge.
|
|
80
|
+
if (!removedQueueRecoveryAvailable(report, checks, true))
|
|
81
|
+
return undefined;
|
|
82
|
+
const removal = report.mergeQueue.latestRemoval;
|
|
83
|
+
return buildPrShepherdCommand([
|
|
84
|
+
"apply",
|
|
85
|
+
"queue-removal",
|
|
86
|
+
formatPrUrl(report.repo, report.pr),
|
|
87
|
+
"--require-sha",
|
|
88
|
+
report.headSha,
|
|
89
|
+
"--queue-commit",
|
|
90
|
+
removal.beforeCommitOid,
|
|
91
|
+
"--removed-at",
|
|
92
|
+
String(removal.createdAtUnix),
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
function removedQueueRecoveryAvailable(report, checks, merge) {
|
|
96
|
+
const queue = report.mergeQueue;
|
|
97
|
+
const removedCommit = queue?.latestRemoval?.beforeCommitOid;
|
|
98
|
+
if (!merge ||
|
|
99
|
+
!queue?.enabled ||
|
|
100
|
+
queue.inQueue ||
|
|
101
|
+
queue.headUpdatedAfterRemoval ||
|
|
102
|
+
// GitHub exposes a raw string, not a capability to reverse a human's queue removal.
|
|
103
|
+
// Only known CI-driven reasons authorize offering automated recovery.
|
|
104
|
+
!isCiQueueRemovalReason(queue.latestRemoval?.reason) ||
|
|
105
|
+
!queue.latestRemoval ||
|
|
106
|
+
queue.latestRemoval.createdAtUnix <= 0 ||
|
|
107
|
+
!removedCommit ||
|
|
108
|
+
!report.headSha ||
|
|
109
|
+
!report.nodeId ||
|
|
110
|
+
!checks.some((check) => check.scope === "merge_group" && check.commitOid === removedCommit))
|
|
111
|
+
return false;
|
|
112
|
+
return checks
|
|
113
|
+
.filter((check) => check.scope === "merge_group" && check.commitOid === removedCommit)
|
|
114
|
+
.every(hasQueueRecoveryEvidence);
|
|
115
|
+
}
|
|
58
116
|
export function unavailableMergeResult(base, report, unavailable) {
|
|
59
117
|
const escalateBase = {
|
|
60
118
|
triggers: ["merge-method-unavailable"],
|
|
@@ -8,6 +8,10 @@ import { createHash } from "node:crypto";
|
|
|
8
8
|
export function fingerprintRawSummaryPr(raw) {
|
|
9
9
|
if (!raw.updatedAt || !raw.headRefOid || !raw.baseRefOid)
|
|
10
10
|
return null;
|
|
11
|
+
const reviewTruncated = raw.comments.pageInfo.hasPreviousPage ||
|
|
12
|
+
raw.reviews.pageInfo.hasPreviousPage ||
|
|
13
|
+
raw.reviewThreads.pageInfo.hasPreviousPage ||
|
|
14
|
+
raw.reviewThreads.nodes.some((thread) => thread.comments.pageInfo.hasPreviousPage);
|
|
11
15
|
const evidence = {
|
|
12
16
|
number: raw.number,
|
|
13
17
|
state: raw.state,
|
|
@@ -19,6 +23,9 @@ export function fingerprintRawSummaryPr(raw) {
|
|
|
19
23
|
reviewDecision: raw.reviewDecision,
|
|
20
24
|
reviewRequests: raw.reviewRequests,
|
|
21
25
|
latestReviews: raw.latestReviews,
|
|
26
|
+
// Omitted bodies cannot participate in the hash. Bind truncated evidence
|
|
27
|
+
// to the PR revision instead, so updates require a fresh full review poll.
|
|
28
|
+
...(reviewTruncated && { reviewUpdatedAt: raw.updatedAt }),
|
|
22
29
|
comments: hideBodies(raw.comments),
|
|
23
30
|
reviews: hideBodies(raw.reviews),
|
|
24
31
|
reviewThreads: {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type RepoInfo } from "./client.mts";
|
|
2
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
3
|
+
/**
|
|
4
|
+
* Overlay REST mergeability onto an open summary PR whose GraphQL snapshot is `UNKNOWN`, in place.
|
|
5
|
+
*
|
|
6
|
+
* GraphQL keeps reporting `UNKNOWN` while GitHub recomputes mergeability after the
|
|
7
|
+
* base moves; REST `GET /pulls/{n}` triggers that computation and usually answers
|
|
8
|
+
* `dirty` at once. Without this, a layer that just gained a conflict routes as
|
|
9
|
+
* pending until a later tick, matching `iterate`'s `refreshUnknownMergeability`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function refreshUnknownSummaryMergeability(raw: RawSummaryPr, repo: RepoInfo): Promise<void>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getMergeableState } from "./client.mjs";
|
|
2
|
+
/**
|
|
3
|
+
* Overlay REST mergeability onto an open summary PR whose GraphQL snapshot is `UNKNOWN`, in place.
|
|
4
|
+
*
|
|
5
|
+
* GraphQL keeps reporting `UNKNOWN` while GitHub recomputes mergeability after the
|
|
6
|
+
* base moves; REST `GET /pulls/{n}` triggers that computation and usually answers
|
|
7
|
+
* `dirty` at once. Without this, a layer that just gained a conflict routes as
|
|
8
|
+
* pending until a later tick, matching `iterate`'s `refreshUnknownMergeability`.
|
|
9
|
+
*/
|
|
10
|
+
export async function refreshUnknownSummaryMergeability(raw, repo) {
|
|
11
|
+
if (raw.state !== "OPEN")
|
|
12
|
+
return;
|
|
13
|
+
if (raw.mergeable !== "UNKNOWN" && raw.mergeStateStatus !== "UNKNOWN")
|
|
14
|
+
return;
|
|
15
|
+
const rest = await getMergeableState(raw.number, repo.owner, repo.name);
|
|
16
|
+
// REST can still be computing; keep a field GraphQL already knew rather than erase it.
|
|
17
|
+
if (rest.mergeable !== "UNKNOWN")
|
|
18
|
+
raw.mergeable = rest.mergeable;
|
|
19
|
+
if (rest.mergeStateStatus !== "UNKNOWN")
|
|
20
|
+
raw.mergeStateStatus = rest.mergeStateStatus;
|
|
21
|
+
if (rest.state !== undefined)
|
|
22
|
+
raw.state = rest.state;
|
|
23
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
|
|
2
|
+
import { parseBranchRules } from "./batch-parsers-rules.mjs";
|
|
3
|
+
import { rulesComplete } from "./fingerprint-fields.mjs";
|
|
2
4
|
/** Fresh compact evidence required before a READY receipt can be used. */
|
|
3
5
|
export function isCurrentSummaryReady(raw, checks, review, options = {}) {
|
|
4
6
|
const queued = options.allowQueuedProgress === true && raw.isInMergeQueue;
|
|
@@ -8,9 +10,17 @@ export function isCurrentSummaryReady(raw, checks, review, options = {}) {
|
|
|
8
10
|
const sourceChecks = queued
|
|
9
11
|
? summarizePollSummaryChecks({ ...raw, mergeQueueEntry: null })
|
|
10
12
|
: checks;
|
|
13
|
+
// The full one-PR check already surfaces review feedback. Certification
|
|
14
|
+
// need not reread historical conversations unless their resolution is a
|
|
15
|
+
// merge requirement. GitHub's CLEAN state proves that requirement is
|
|
16
|
+
// satisfied. BLOCKED is not conversation-specific, and queue progress
|
|
17
|
+
// alone is not that proof.
|
|
18
|
+
const requiresConversationResolution = parseBranchRules(raw.baseRef).requiresConversationResolution;
|
|
11
19
|
return (checks.incomplete !== true &&
|
|
12
20
|
sourceChecks.incomplete !== true &&
|
|
13
|
-
review.incomplete !== true
|
|
21
|
+
(review.incomplete !== true ||
|
|
22
|
+
raw.mergeStateStatus === "CLEAN" ||
|
|
23
|
+
(rulesComplete(raw.baseRef) && !requiresConversationResolution)) &&
|
|
14
24
|
raw.state === "OPEN" &&
|
|
15
25
|
!raw.isDraft &&
|
|
16
26
|
raw.mergeable !== "CONFLICTING" &&
|
|
@@ -2,6 +2,7 @@ import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
|
2
2
|
import { graphqlWithRateLimit } from "./client.mjs";
|
|
3
3
|
import { GitHubRequestError, isRetryableGraphQlResourceLimit, missingRepositoryError, } from "./errors.mjs";
|
|
4
4
|
import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
|
|
5
|
+
import { refreshUnknownSummaryMergeability } from "./poll-summary-mergeability.mjs";
|
|
5
6
|
import { summarizePollSummaryPr } from "./poll-summary-projector.mjs";
|
|
6
7
|
import { trunkRequiredContexts } from "./poll-summary-unreported.mjs";
|
|
7
8
|
import { POLL_STACK_SUMMARY_QUERY, POLL_SUMMARY_FRAGMENT } from "./queries.mjs";
|
|
@@ -52,8 +53,10 @@ async function fetchExplicitChunk(prs, repo) {
|
|
|
52
53
|
throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
|
|
53
54
|
return raw;
|
|
54
55
|
});
|
|
55
|
-
for (const raw of rawPrs)
|
|
56
|
+
for (const raw of rawPrs) {
|
|
56
57
|
await hydratePollSummaryChecks(raw, repo);
|
|
58
|
+
await refreshUnknownSummaryMergeability(raw, repo);
|
|
59
|
+
}
|
|
57
60
|
return { prs: rawPrs, viewerCanAdminister: result.data.repository.viewerCanAdminister };
|
|
58
61
|
}
|
|
59
62
|
/**
|
|
@@ -64,8 +67,10 @@ async function fetchStackSummary(opts, repo) {
|
|
|
64
67
|
const anchor = opts.stackPrNumber;
|
|
65
68
|
const topology = await readStackTopology(anchor, repo);
|
|
66
69
|
const { stackNumber, stackSize, viewerLogin, viewerCanAdminister, ordered, allowedMergeMethods } = await readStackSummary(anchor, repo, topology.stackSize);
|
|
67
|
-
for (const pr of ordered)
|
|
70
|
+
for (const pr of ordered) {
|
|
68
71
|
await hydratePollSummaryChecks(pr, repo);
|
|
72
|
+
await refreshUnknownSummaryMergeability(pr, repo);
|
|
73
|
+
}
|
|
69
74
|
const stackAncestry = stackAncestryGaps(ordered);
|
|
70
75
|
const required = trunkRequiredContexts(ordered);
|
|
71
76
|
return {
|
package/bin/mcp/server.mjs
CHANGED
|
@@ -60,6 +60,12 @@ const appendJournalOperationSchema = z.object({
|
|
|
60
60
|
item: z.string(),
|
|
61
61
|
dryRun: z.boolean().optional(),
|
|
62
62
|
});
|
|
63
|
+
const acknowledgeQueueRemovalOperationSchema = z.object({
|
|
64
|
+
type: z.literal("acknowledge_queue_removal"),
|
|
65
|
+
requireSha: z.string().regex(/^[0-9a-f]{40}$/),
|
|
66
|
+
queueCommitOid: z.string().regex(/^[0-9a-f]{40}$/),
|
|
67
|
+
removedAtUnix: z.number().int().positive().safe(),
|
|
68
|
+
});
|
|
63
69
|
const applyInputSchema = z.object({
|
|
64
70
|
pr,
|
|
65
71
|
operations: z
|
|
@@ -67,6 +73,7 @@ const applyInputSchema = z.object({
|
|
|
67
73
|
reviewMutationsOperationSchema,
|
|
68
74
|
markFilesViewedOperationSchema,
|
|
69
75
|
appendJournalOperationSchema,
|
|
76
|
+
acknowledgeQueueRemovalOperationSchema,
|
|
70
77
|
]))
|
|
71
78
|
.min(1),
|
|
72
79
|
});
|
|
@@ -234,6 +241,17 @@ function formatApplyResult(result) {
|
|
|
234
241
|
return `${heading}\n\n${formatMarkFilesAsViewedResult(operation.result)}`;
|
|
235
242
|
case "append_journal":
|
|
236
243
|
return `${heading}\n\n${formatJournalResult(operation.result)}`;
|
|
244
|
+
case "acknowledge_queue_removal": {
|
|
245
|
+
const result = operation.result;
|
|
246
|
+
return [
|
|
247
|
+
heading,
|
|
248
|
+
"",
|
|
249
|
+
`PR: ${result.repo}#${result.pr}`,
|
|
250
|
+
`headSha: ${result.acknowledgment.headSha}`,
|
|
251
|
+
`queueCommitOid: ${result.acknowledgment.queueCommitOid}`,
|
|
252
|
+
`removedAtUnix: ${result.acknowledgment.removedAtUnix}`,
|
|
253
|
+
].join("\n");
|
|
254
|
+
}
|
|
237
255
|
}
|
|
238
256
|
})
|
|
239
257
|
.join("\n\n");
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** A caller's acknowledgment of one observed CI-driven native-stack queue removal. */
|
|
2
|
+
export interface QueueRemovalAcknowledgment {
|
|
3
|
+
headSha: string;
|
|
4
|
+
queueCommitOid: string;
|
|
5
|
+
removedAtUnix: number;
|
|
6
|
+
}
|
|
7
|
+
type StateKey = {
|
|
8
|
+
owner: string;
|
|
9
|
+
repo: string;
|
|
10
|
+
pr: number;
|
|
11
|
+
};
|
|
12
|
+
/** The only removal reasons that indicate a CI-driven queue ejection. */
|
|
13
|
+
export declare function isCiQueueRemovalReason(reason: string | null | undefined): boolean;
|
|
14
|
+
/** Missing, unreadable, or malformed acknowledgment state is treated as absent. */
|
|
15
|
+
export declare function readQueueRemovalAcknowledgment(key: StateKey): Promise<QueueRemovalAcknowledgment | null>;
|
|
16
|
+
/** Atomically store one acknowledgment. */
|
|
17
|
+
export declare function writeQueueRemovalAcknowledgment(key: StateKey, acknowledgment: QueueRemovalAcknowledgment): Promise<boolean>;
|
|
18
|
+
export declare function matchesQueueRemovalAcknowledgment(acknowledgment: QueueRemovalAcknowledgment | null, expected: Pick<QueueRemovalAcknowledgment, "headSha" | "queueCommitOid" | "removedAtUnix">): acknowledgment is QueueRemovalAcknowledgment;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** A caller's acknowledgment of one observed CI-driven native-stack queue removal. */
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { resolvePrStatePath } from "./base.mjs";
|
|
6
|
+
const FILE = "queue-removal-ack.json";
|
|
7
|
+
/** The only removal reasons that indicate a CI-driven queue ejection. */
|
|
8
|
+
export function isCiQueueRemovalReason(reason) {
|
|
9
|
+
return reason === "CI_FAILURE" || reason === "MERGE_QUEUE_POLICY_CHECK_FAILURE";
|
|
10
|
+
}
|
|
11
|
+
/** Missing, unreadable, or malformed acknowledgment state is treated as absent. */
|
|
12
|
+
export async function readQueueRemovalAcknowledgment(key) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(await readFile(resolvePrStatePath(key, FILE), "utf8"));
|
|
15
|
+
return isAcknowledgment(parsed) ? parsed : null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Atomically store one acknowledgment. */
|
|
22
|
+
export async function writeQueueRemovalAcknowledgment(key, acknowledgment) {
|
|
23
|
+
if (!isAcknowledgment(acknowledgment))
|
|
24
|
+
return false;
|
|
25
|
+
let tmp;
|
|
26
|
+
try {
|
|
27
|
+
const path = resolvePrStatePath(key, FILE);
|
|
28
|
+
tmp = `${path}.${randomUUID()}.tmp`;
|
|
29
|
+
await mkdir(dirname(path), { recursive: true });
|
|
30
|
+
await writeFile(tmp, `${JSON.stringify(acknowledgment)}\n`, "utf8");
|
|
31
|
+
await rename(tmp, path);
|
|
32
|
+
tmp = undefined;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
if (tmp !== undefined) {
|
|
40
|
+
try {
|
|
41
|
+
await unlink(tmp);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// A failed write may not have created the temporary file.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function matchesQueueRemovalAcknowledgment(acknowledgment, expected) {
|
|
50
|
+
return (acknowledgment !== null &&
|
|
51
|
+
acknowledgment.headSha === expected.headSha &&
|
|
52
|
+
acknowledgment.queueCommitOid === expected.queueCommitOid &&
|
|
53
|
+
acknowledgment.removedAtUnix === expected.removedAtUnix);
|
|
54
|
+
}
|
|
55
|
+
function isAcknowledgment(value) {
|
|
56
|
+
if (value === null || typeof value !== "object")
|
|
57
|
+
return false;
|
|
58
|
+
const candidate = value;
|
|
59
|
+
return (typeof candidate.headSha === "string" &&
|
|
60
|
+
/^[0-9a-f]{40}$/.test(candidate.headSha) &&
|
|
61
|
+
typeof candidate.queueCommitOid === "string" &&
|
|
62
|
+
/^[0-9a-f]{40}$/.test(candidate.queueCommitOid) &&
|
|
63
|
+
typeof candidate.removedAtUnix === "number" &&
|
|
64
|
+
Number.isSafeInteger(candidate.removedAtUnix) &&
|
|
65
|
+
candidate.removedAtUnix > 0);
|
|
66
|
+
}
|
package/bin/types/iterate.d.mts
CHANGED
|
@@ -124,6 +124,10 @@ interface FixRebaseAndPush {
|
|
|
124
124
|
protectedRuns: ProtectedRun[];
|
|
125
125
|
/** Requeue command emitted after merge-group remediation. */
|
|
126
126
|
requeue?: MergeCommandPlan;
|
|
127
|
+
/** Acknowledge an unrelated native-stack queue failure before fresh READY validation. */
|
|
128
|
+
queueRemovalAcknowledgment?: {
|
|
129
|
+
argv: string[];
|
|
130
|
+
};
|
|
127
131
|
/** First-look threads — previously hidden, surfaced for acknowledgment only. */
|
|
128
132
|
firstLookThreads: FirstLookThread[];
|
|
129
133
|
/** First-look comments — previously hidden, surfaced for acknowledgment only. */
|
|
@@ -10,6 +10,8 @@ export interface MergeQueueReport {
|
|
|
10
10
|
checksIncomplete?: true;
|
|
11
11
|
/** The current PR head is not a parent of the removed synthetic queue commit. */
|
|
12
12
|
headUpdatedAfterRemoval?: true;
|
|
13
|
+
/** The caller acknowledged this exact native-stack removal on the current head. */
|
|
14
|
+
removalAcknowledged?: true;
|
|
13
15
|
}
|
|
14
16
|
/**
|
|
15
17
|
* Raw counts of actionable work held back while the PR sits in the merge queue
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ allowed-tools: ["MCP", "Bash", "Read", "Grep", "Glob", "Edit", "Write"]
|
|
|
8
8
|
|
|
9
9
|
# pr-shepherd
|
|
10
10
|
|
|
11
|
-
Poll with the CLI. Use MCP `iterate` only when the CLI is unavailable. Stop at `[CANCEL]` or `[ESCALATE]`.
|
|
11
|
+
Poll with the CLI. Use MCP `iterate` only when the CLI is unavailable. Stop polling the selected pull request at `[CANCEL]` or `[ESCALATE]`.
|
|
12
12
|
|
|
13
13
|
## Create a PR
|
|
14
14
|
|
|
@@ -21,7 +21,8 @@ Poll with the CLI. Use MCP `iterate` only when the CLI is unavailable. Stop at `
|
|
|
21
21
|
## Dispatch
|
|
22
22
|
|
|
23
23
|
- Parse `$ARGUMENTS` for PR numbers, `owner/repo#N`, GitHub PR URLs, one `--stack PR`, and an optional `--merge`. Reject any other argument.
|
|
24
|
-
- A
|
|
24
|
+
- A user-supplied `--merge` explicitly authorizes merging or enqueueing the selected PR or stack. Run the emitted merge/enqueue commands without asking for another conversational confirmation; request runtime escalation when the host requires it.
|
|
25
|
+
- A request to merge, land, or enqueue the selected PR or stack also sets `--merge`. Creating or opening a PR without merge intent leaves merge mode off.
|
|
25
26
|
- A request to shepherd or merge a native stack, with an anchor PR and no literal `--stack`, uses that PR as the `--stack` selector. Otherwise infer the current branch PR.
|
|
26
27
|
- Follow the target repository's `AGENTS.md` while editing.
|
|
27
28
|
- CLI: turn `owner/repo#N` into `https://github.com/owner/repo/pull/N`. Pass other URLs and bare numbers through.
|
|
@@ -6,6 +6,7 @@ Apply when a step says `Playbook: "CI failure triage"`. For a GitHub Actions row
|
|
|
6
6
|
- `[rerun authorized]` plus a `rerun:` command means the viewer can rerun Actions (WRITE+) and this is the original attempt. Shepherd checked `repositoryPermission` and `run_attempt`.
|
|
7
7
|
- Run that printed command at most once. An `[attempt: N]` check never gets another rerun. A log excerpt on a later attempt is still investigation work. A later attempt with no usable evidence escalates when nothing else remains.
|
|
8
8
|
- A run in progress, `[conclusion: ACTION_REQUIRED]`, a check whose run id is not a GitHub Actions workflow, or a run with no attempt metadata never gets `[rerun authorized]`.
|
|
9
|
+
- A check with `scope: merge_group` never gets a rerun command. Rerunning cannot restore a removed queue entry and overwrites the failure evidence. If the failure belongs to this PR, fix the PR head. If it does not, follow the printed requeue instruction when a plan is present. Without `--merge`, report the failure without enqueueing. For native stacks, child sessions omit `--merge` and may still record the printed local acknowledgment after inspecting logs or an external provider URL. Complete fresh one-PR READY validation before returning to the aggregate selector with its original options; never enqueue a stack layer directly.
|
|
9
10
|
- Do not invent a handoff from `[FIX_CODE]`. Shepherd returns `[ESCALATE]` when no autonomous follow-up remains.
|
|
10
11
|
- Several bullets can share one run id (matrix jobs). The `rerun:` command is printed once, on the first bullet. Run it once.
|
|
11
12
|
|