pr-shepherd 0.42.0 → 0.43.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 +8 -3
- package/bin/checks/classify.d.mts +3 -1
- package/bin/checks/classify.mjs +5 -2
- package/bin/cli/args.mjs +1 -0
- package/bin/cli/default-poll.mjs +1 -0
- package/bin/cli/fix-formatter.mjs +12 -2
- package/bin/cli/handlers.mjs +1 -0
- package/bin/cli/help-command-pages.d.mts +2 -2
- package/bin/cli/help-iterate-poll-pages.d.mts +2 -2
- package/bin/cli/help-iterate-poll-pages.mjs +7 -2
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/iterate-flags.d.mts +1 -0
- package/bin/cli/iterate-flags.mjs +2 -0
- package/bin/cli/iterate-formatter.mjs +4 -1
- package/bin/cli/iterate-instructions.mjs +15 -0
- package/bin/cli/iterate-lean.d.mts +1 -2
- package/bin/cli/iterate-lean.mjs +12 -17
- package/bin/cli/iterate-merge-formatter.d.mts +3 -0
- package/bin/cli/iterate-merge-formatter.mjs +52 -0
- package/bin/cli/iterate-verbose.d.mts +6 -0
- package/bin/cli/iterate-verbose.mjs +14 -0
- package/bin/cli/poll-handler.mjs +1 -0
- package/bin/cli/runner.mjs +4 -3
- package/bin/commands/check-terminal-report.mjs +1 -0
- package/bin/commands/check.mjs +42 -1
- package/bin/commands/iterate/base.d.mts +5 -0
- package/bin/commands/iterate/base.mjs +25 -0
- package/bin/commands/iterate/escalate.d.mts +3 -1
- package/bin/commands/iterate/escalate.mjs +18 -2
- package/bin/commands/iterate/fix-code.mjs +16 -0
- package/bin/commands/iterate/helpers.mjs +6 -2
- package/bin/commands/iterate/index.mjs +29 -35
- package/bin/commands/iterate/merge-state.d.mts +15 -0
- package/bin/commands/iterate/merge-state.mjs +53 -0
- package/bin/commands/iterate/merge.d.mts +13 -0
- package/bin/commands/iterate/merge.mjs +46 -0
- package/bin/commands/poll.mjs +3 -1
- package/bin/config/load.d.mts +4 -0
- package/bin/config/load.mjs +34 -0
- package/bin/config/merge-command-args.d.mts +2 -0
- package/bin/config/merge-command-args.mjs +44 -0
- package/bin/config.json +3 -0
- package/bin/exit-codes.d.mts +2 -0
- package/bin/exit-codes.mjs +4 -0
- package/bin/github/batch-parse-checks.d.mts +3 -0
- package/bin/github/batch-parse-checks.mjs +29 -0
- package/bin/github/batch-parsers-rules.d.mts +3 -1
- package/bin/github/batch-parsers-rules.mjs +33 -0
- package/bin/github/batch-parsers.mjs +23 -26
- package/bin/github/batch-raw-rules.d.mts +48 -0
- package/bin/github/batch-raw-types.mjs +0 -1
- package/bin/github/batch.mjs +2 -0
- package/bin/github/gql/batch-pr.gql +92 -0
- package/bin/github/gql/commit-check-contexts.gql +58 -0
- package/bin/github/merge-queue-checks.d.mts +4 -0
- package/bin/github/merge-queue-checks.mjs +48 -0
- package/bin/github/queries.d.mts +2 -0
- package/bin/github/queries.mjs +2 -0
- package/bin/mcp/server.mjs +1 -0
- package/bin/reporters/agent.mjs +6 -3
- package/bin/types/activity.d.mts +2 -0
- package/bin/types/escalate.d.mts +30 -0
- package/bin/types/escalate.mjs +1 -0
- package/bin/types/github.d.mts +8 -0
- package/bin/types/github.mjs +0 -3
- package/bin/types/iterate.d.mts +14 -31
- package/bin/types/merge-action.d.mts +12 -0
- package/bin/types/merge-action.mjs +1 -0
- package/bin/types/merge-queue.d.mts +13 -0
- package/bin/types/merge-queue.mjs +1 -0
- package/bin/types/merge-requirements.d.mts +17 -0
- package/bin/types/report.d.mts +8 -0
- package/bin/types.d.mts +3 -0
- package/bin/types.mjs +3 -0
- package/bin/util/markdown.d.mts +2 -0
- package/bin/util/markdown.mjs +7 -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 -3
package/bin/commands/check.mjs
CHANGED
|
@@ -42,7 +42,22 @@ export async function runCheck(opts) {
|
|
|
42
42
|
? []
|
|
43
43
|
: await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber);
|
|
44
44
|
const allChecks = mergeStartupFailureChecks(batchData.checks, startupFailureChecks);
|
|
45
|
-
const
|
|
45
|
+
const classifiedPrChecks = classifyChecks(allChecks);
|
|
46
|
+
const latestRemoval = batchData.latestMergeQueueRemoval;
|
|
47
|
+
const headUpdatedAfterRemoval = Boolean(latestRemoval &&
|
|
48
|
+
latestRemoval.beforeCommitParentOids &&
|
|
49
|
+
!latestRemoval.beforeCommitParentOids.includes(batchData.headRefOid));
|
|
50
|
+
const queueRawChecks = batchData.isInMergeQueue
|
|
51
|
+
? (batchData.mergeQueueChecks ?? [])
|
|
52
|
+
: latestRemoval && !headUpdatedAfterRemoval
|
|
53
|
+
? (batchData.removedMergeQueueChecks ?? [])
|
|
54
|
+
: [];
|
|
55
|
+
// Keep supersession grouping commit-local, but accept merge_group only for the
|
|
56
|
+
// synthetic queue-commit source.
|
|
57
|
+
const classifiedQueueChecks = classifyChecks(queueRawChecks, {
|
|
58
|
+
additionalRelevantEvents: ["merge_group"],
|
|
59
|
+
});
|
|
60
|
+
const classifiedChecks = [...classifiedPrChecks, ...classifiedQueueChecks];
|
|
46
61
|
const verdict = getCiVerdict(classifiedChecks);
|
|
47
62
|
const passing = classifiedChecks.filter((c) => c.category === "passed");
|
|
48
63
|
const failing = classifiedChecks.filter((c) => c.category === "failing");
|
|
@@ -131,9 +146,17 @@ export async function runCheck(opts) {
|
|
|
131
146
|
}
|
|
132
147
|
}
|
|
133
148
|
const blockedByFilteredCheck = isBlockedByFilteredCheck(mergeStatus, verdict);
|
|
149
|
+
const queueCommit = batchData.isInMergeQueue
|
|
150
|
+
? batchData.mergeQueueEntry?.headCommitOid
|
|
151
|
+
: batchData.latestMergeQueueRemoval?.beforeCommitOid;
|
|
152
|
+
const hasQueueState = Boolean(batchData.isMergeQueueEnabled ||
|
|
153
|
+
batchData.isInMergeQueue ||
|
|
154
|
+
batchData.autoMergeRequest ||
|
|
155
|
+
batchData.latestMergeQueueRemoval);
|
|
134
156
|
return {
|
|
135
157
|
pr: prNumber,
|
|
136
158
|
nodeId: batchData.nodeId,
|
|
159
|
+
headSha: batchData.headRefOid,
|
|
137
160
|
repo: `${repo.owner}/${repo.name}`,
|
|
138
161
|
status,
|
|
139
162
|
baseBranch: batchData.baseRefName,
|
|
@@ -175,6 +198,24 @@ export async function runCheck(opts) {
|
|
|
175
198
|
: undefined),
|
|
176
199
|
branchProtection: batchData.branchProtection,
|
|
177
200
|
activity: batchData.activity,
|
|
201
|
+
...(hasQueueState && {
|
|
202
|
+
mergeQueue: {
|
|
203
|
+
enabled: Boolean(batchData.isMergeQueueEnabled),
|
|
204
|
+
inQueue: Boolean(batchData.isInMergeQueue),
|
|
205
|
+
...(batchData.autoMergeRequest && { autoMergeRequest: batchData.autoMergeRequest }),
|
|
206
|
+
...(batchData.mergeQueueEntry && { entry: batchData.mergeQueueEntry }),
|
|
207
|
+
...(batchData.latestMergeQueueRemoval && {
|
|
208
|
+
latestRemoval: batchData.latestMergeQueueRemoval,
|
|
209
|
+
}),
|
|
210
|
+
...(queueCommit && { checkCommitOid: queueCommit }),
|
|
211
|
+
...((batchData.isInMergeQueue
|
|
212
|
+
? batchData.mergeQueueChecksIncomplete
|
|
213
|
+
: batchData.removedMergeQueueChecksIncomplete) && {
|
|
214
|
+
checksIncomplete: true,
|
|
215
|
+
}),
|
|
216
|
+
...(headUpdatedAfterRemoval && { headUpdatedAfterRemoval: true }),
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
178
219
|
};
|
|
179
220
|
}
|
|
180
221
|
async function remainingRuleAutoResolveIds(partition, autoMinimizeSuppressed = false) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { buildActiveChecks, buildRelevantChecks, buildSummary, buildSuppressedCheckFields, } from "./helpers.mjs";
|
|
2
|
+
export function buildIterateBase(report, readyState) {
|
|
3
|
+
return {
|
|
4
|
+
pr: report.pr,
|
|
5
|
+
repo: report.repo,
|
|
6
|
+
status: report.status,
|
|
7
|
+
state: report.mergeStatus.state,
|
|
8
|
+
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
9
|
+
mergeStatus: report.mergeStatus.status,
|
|
10
|
+
reviewDecision: report.mergeStatus.reviewDecision,
|
|
11
|
+
blockingBotReviewInProgress: report.mergeStatus.blockingBotReviewInProgress,
|
|
12
|
+
isDraft: report.mergeStatus.isDraft,
|
|
13
|
+
shouldCancel: readyState.shouldCancel,
|
|
14
|
+
remainingSeconds: readyState.remainingSeconds,
|
|
15
|
+
summary: buildSummary(report),
|
|
16
|
+
baseBranch: report.baseBranch,
|
|
17
|
+
branchProtection: report.branchProtection,
|
|
18
|
+
mergeRequirements: report.mergeStatus.mergeRequirements,
|
|
19
|
+
checks: buildRelevantChecks(report),
|
|
20
|
+
inProgressChecks: buildActiveChecks(report),
|
|
21
|
+
...buildSuppressedCheckFields(report),
|
|
22
|
+
activity: report.activity,
|
|
23
|
+
mergeQueue: report.mergeQueue,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -26,6 +26,8 @@ interface BaseBranchLookup {
|
|
|
26
26
|
*/
|
|
27
27
|
export declare function formatDurationApprox(seconds: number): string;
|
|
28
28
|
export declare function validateBaseBranch(raw: string): BaseBranchLookup;
|
|
29
|
-
export declare function buildEscalateHumanMessage(escalate: Omit<EscalateDetails, "humanMessage">, pr: number
|
|
29
|
+
export declare function buildEscalateHumanMessage(escalate: Omit<EscalateDetails, "humanMessage">, pr: number, opts?: {
|
|
30
|
+
merge?: boolean;
|
|
31
|
+
}): string;
|
|
30
32
|
export declare function buildEscalateSuggestion(triggers: EscalateTrigger[], detail?: string): string;
|
|
31
33
|
export {};
|
|
@@ -59,7 +59,7 @@ export function validateBaseBranch(raw) {
|
|
|
59
59
|
}
|
|
60
60
|
return { branch: trimmed, isFallback: false };
|
|
61
61
|
}
|
|
62
|
-
export function buildEscalateHumanMessage(escalate, pr) {
|
|
62
|
+
export function buildEscalateHumanMessage(escalate, pr, opts) {
|
|
63
63
|
const lines = [];
|
|
64
64
|
lines.push("⚠️ /pr-shepherd:pr-shepherd paused — manual intervention required");
|
|
65
65
|
lines.push("");
|
|
@@ -109,6 +109,18 @@ export function buildEscalateHumanMessage(escalate, pr) {
|
|
|
109
109
|
lines.push("");
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
if (escalate.mergeQueueRemoval) {
|
|
113
|
+
const removal = escalate.mergeQueueRemoval;
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push("## Merge queue removal");
|
|
116
|
+
lines.push("");
|
|
117
|
+
lines.push(`- reason: \`${removal.reason ?? "not provided"}\``);
|
|
118
|
+
if (removal.actor)
|
|
119
|
+
lines.push(`- actor: \`@${removal.actor}\``);
|
|
120
|
+
lines.push(`- createdAtUnix: \`${removal.createdAtUnix}\``);
|
|
121
|
+
if (removal.beforeCommitOid)
|
|
122
|
+
lines.push(`- queue commit: \`${removal.beforeCommitOid}\``);
|
|
123
|
+
}
|
|
112
124
|
if (escalate.thrashHistory && escalate.thrashHistory.length > 0) {
|
|
113
125
|
lines.push("");
|
|
114
126
|
lines.push("## Fix attempts");
|
|
@@ -120,10 +132,14 @@ export function buildEscalateHumanMessage(escalate, pr) {
|
|
|
120
132
|
lines.push("");
|
|
121
133
|
lines.push("---");
|
|
122
134
|
lines.push("");
|
|
123
|
-
lines.push(`After completing manual fixes (and pushing if required), rerun \`/pr-shepherd:pr-shepherd ${pr}\` to resume.`);
|
|
135
|
+
lines.push(`After completing manual fixes (and pushing if required), rerun \`/pr-shepherd:pr-shepherd ${pr}${opts?.merge ? " --merge" : ""}\` to resume.`);
|
|
124
136
|
return lines.join("\n");
|
|
125
137
|
}
|
|
126
138
|
export function buildEscalateSuggestion(triggers, detail) {
|
|
139
|
+
if (triggers.includes("merge-queue-removed")) {
|
|
140
|
+
const reason = detail ? ` GitHub reason: ${detail}.` : "";
|
|
141
|
+
return `The PR left the merge queue without an actionable check failure.${reason} Confirm the removal was not intentional before adding it again.`;
|
|
142
|
+
}
|
|
127
143
|
if (triggers.includes("stall-timeout")) {
|
|
128
144
|
const duration = detail ?? "60 minutes";
|
|
129
145
|
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.`;
|
|
@@ -12,6 +12,7 @@ import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-
|
|
|
12
12
|
import { threadTranscriptBody } from "../../threads/transcript.mjs";
|
|
13
13
|
import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
|
|
14
14
|
import { loadConfig } from "../../config/load.mjs";
|
|
15
|
+
import { buildMergeCommandPlan } from "./merge.mjs";
|
|
15
16
|
function nextFixAttempts(stored, headSha, threads) {
|
|
16
17
|
const threadAttempts = stored ? { ...stored.threadAttempts } : {};
|
|
17
18
|
const threadBodyHashes = stored?.threadBodyHashes
|
|
@@ -148,6 +149,20 @@ export async function handleFixCode(ctx) {
|
|
|
148
149
|
const firstLookThreads = report.threads.firstLook;
|
|
149
150
|
const firstLookComments = report.comments.firstLook;
|
|
150
151
|
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind);
|
|
152
|
+
const hasMergeGroupFailure = failingChecks.some((check) => check.scope === "merge_group");
|
|
153
|
+
const needsRequeue = Boolean(hasMergeGroupFailure || report.mergeQueue?.inQueue || report.mergeQueue?.latestRemoval);
|
|
154
|
+
const requeue = opts.merge && needsRequeue
|
|
155
|
+
? buildMergeCommandPlan({
|
|
156
|
+
pr: report.pr,
|
|
157
|
+
repo: report.repo,
|
|
158
|
+
nodeId: report.nodeId,
|
|
159
|
+
headSha: "$HEAD_SHA",
|
|
160
|
+
queue: true,
|
|
161
|
+
})
|
|
162
|
+
: undefined;
|
|
163
|
+
if (requeue) {
|
|
164
|
+
instructions.splice(Math.max(0, instructions.length - 1), 0, "After all fixes and review mutations, replace `$HEAD_SHA` in the requeue commands with the full pushed PR-head SHA (or `$(git rev-parse HEAD)`). If GitHub no longer reports the PR in the merge queue, run the `requeue:` command shown above; if the gh CLI reports that auto-merge is disabled, run `requeue API fallback:` instead.");
|
|
165
|
+
}
|
|
151
166
|
const prospectiveResult = {
|
|
152
167
|
...base,
|
|
153
168
|
baseBranch: baseLookup.branch,
|
|
@@ -169,6 +184,7 @@ export async function handleFixCode(ctx) {
|
|
|
169
184
|
firstLookComments,
|
|
170
185
|
inProgressRunIds,
|
|
171
186
|
protectedRuns,
|
|
187
|
+
...(requeue && { requeue }),
|
|
172
188
|
},
|
|
173
189
|
cancelled,
|
|
174
190
|
};
|
|
@@ -65,6 +65,8 @@ export function buildRelevantChecks(report) {
|
|
|
65
65
|
runId: c.runId,
|
|
66
66
|
detailsUrl: c.detailsUrl || null,
|
|
67
67
|
...(c.workflowName !== undefined && { workflowName: c.workflowName }),
|
|
68
|
+
...(c.scope !== undefined && { scope: c.scope }),
|
|
69
|
+
...(c.commitOid !== undefined && { commitOid: c.commitOid }),
|
|
68
70
|
summary: c.summary,
|
|
69
71
|
},
|
|
70
72
|
];
|
|
@@ -85,6 +87,8 @@ export function buildRelevantChecks(report) {
|
|
|
85
87
|
...(c.summary !== undefined && { summary: c.summary }),
|
|
86
88
|
...(c.logExcerpt !== undefined && { logExcerpt: c.logExcerpt }),
|
|
87
89
|
...(c.annotations !== undefined && { annotations: c.annotations }),
|
|
90
|
+
...(c.scope !== undefined && { scope: c.scope }),
|
|
91
|
+
...(c.commitOid !== undefined && { commitOid: c.commitOid }),
|
|
88
92
|
},
|
|
89
93
|
];
|
|
90
94
|
});
|
|
@@ -98,9 +102,10 @@ export function buildActiveChecks(report) {
|
|
|
98
102
|
detailsUrl: c.detailsUrl || null,
|
|
99
103
|
...(c.workflowName !== undefined && { workflowName: c.workflowName }),
|
|
100
104
|
...(c.summary !== undefined && { summary: c.summary }),
|
|
105
|
+
...(c.scope !== undefined && { scope: c.scope }),
|
|
106
|
+
...(c.commitOid !== undefined && { commitOid: c.commitOid }),
|
|
101
107
|
}));
|
|
102
108
|
}
|
|
103
|
-
// Best-effort: cancelling a completed run is a no-op, not an error.
|
|
104
109
|
export async function tryCancelRun(runId, owner, repo) {
|
|
105
110
|
try {
|
|
106
111
|
await rest("POST", `/repos/${owner}/${repo}/actions/runs/${runId}/cancel`);
|
|
@@ -108,7 +113,6 @@ export async function tryCancelRun(runId, owner, repo) {
|
|
|
108
113
|
}
|
|
109
114
|
catch (err) {
|
|
110
115
|
const msg = err instanceof Error ? err.message : String(err);
|
|
111
|
-
// GitHub returns 409 when the run reached a terminal state — expected, not worth logging.
|
|
112
116
|
if (/409|already completed|cannot cancel a workflow run that is completed/i.test(msg))
|
|
113
117
|
return null;
|
|
114
118
|
process.stderr.write(`pr-shepherd: cancel run ${runId} failed (ignored): ${msg}\n`);
|
|
@@ -5,7 +5,7 @@ import { graphql } from "../../github/http.mjs";
|
|
|
5
5
|
import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
|
|
6
6
|
import { loadConfig } from "../../config/load.mjs";
|
|
7
7
|
import { EXIT, ShepherdError } from "../../exit-codes.mjs";
|
|
8
|
-
import { getCurrentHeadSha,
|
|
8
|
+
import { getCurrentHeadSha, buildWaitLog, buildTerminalCancelResult, blockedCancelNote, } from "./helpers.mjs";
|
|
9
9
|
import { classifyReviewSummaries } from "./classify.mjs";
|
|
10
10
|
import { applyStallGuard } from "./stall.mjs";
|
|
11
11
|
import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
@@ -13,6 +13,8 @@ import { handleFixCode } from "./fix-code.mjs";
|
|
|
13
13
|
import { normalizeBotUsernames } from "../../comments/authors.mjs";
|
|
14
14
|
import { autoMinimizeComments } from "../../comments/resolve.mjs";
|
|
15
15
|
import { checksWithActionableAnnotations } from "../check-annotations.mjs";
|
|
16
|
+
import { buildReadyMergeResult, handleActiveMergeState } from "./merge-state.mjs";
|
|
17
|
+
import { buildIterateBase } from "./base.mjs";
|
|
16
18
|
export async function runIterate(opts) {
|
|
17
19
|
const config = loadConfig();
|
|
18
20
|
const botUsernames = normalizeBotUsernames(config.botUsernames);
|
|
@@ -75,39 +77,10 @@ export async function runIterate(opts) {
|
|
|
75
77
|
reviewSummaryIds.length > 0 ||
|
|
76
78
|
firstLookSummaries.length > 0 ||
|
|
77
79
|
editedSummaries.length > 0;
|
|
78
|
-
const
|
|
80
|
+
const activeMerge = Boolean(opts.merge && (report.mergeQueue?.inQueue || report.mergeQueue?.autoMergeRequest));
|
|
81
|
+
const isCleanReadyHandoff = report.status === "READY" && !hasActionableWork && !activeMerge;
|
|
79
82
|
const readyState = await updateReadyDelay(report.pr, isCleanReadyHandoff, readyDelaySeconds, repoOwner, repoName);
|
|
80
|
-
const base =
|
|
81
|
-
pr: report.pr,
|
|
82
|
-
repo: report.repo,
|
|
83
|
-
status: report.status,
|
|
84
|
-
state: report.mergeStatus.state,
|
|
85
|
-
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
86
|
-
mergeStatus: report.mergeStatus.status,
|
|
87
|
-
reviewDecision: report.mergeStatus.reviewDecision,
|
|
88
|
-
blockingBotReviewInProgress: report.mergeStatus.blockingBotReviewInProgress,
|
|
89
|
-
isDraft: report.mergeStatus.isDraft,
|
|
90
|
-
shouldCancel: readyState.shouldCancel,
|
|
91
|
-
remainingSeconds: readyState.remainingSeconds,
|
|
92
|
-
summary: buildSummary(report),
|
|
93
|
-
baseBranch: report.baseBranch,
|
|
94
|
-
branchProtection: report.branchProtection,
|
|
95
|
-
mergeRequirements: report.mergeStatus.mergeRequirements,
|
|
96
|
-
checks: buildRelevantChecks(report),
|
|
97
|
-
inProgressChecks: buildActiveChecks(report),
|
|
98
|
-
...buildSuppressedCheckFields(report),
|
|
99
|
-
activity: report.activity,
|
|
100
|
-
};
|
|
101
|
-
if (readyState.shouldCancel) {
|
|
102
|
-
await clearStallState(stallKey);
|
|
103
|
-
const cancelNote = blockedCancelNote(base);
|
|
104
|
-
return {
|
|
105
|
-
...base,
|
|
106
|
-
action: "cancel",
|
|
107
|
-
reason: "ready-delay-elapsed",
|
|
108
|
-
log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
|
|
109
|
-
};
|
|
110
|
-
}
|
|
83
|
+
const base = buildIterateBase(report, readyState);
|
|
111
84
|
const headSha = (await getCurrentHeadSha()) ?? "unknown";
|
|
112
85
|
if (hasActionableWork) {
|
|
113
86
|
return handleFixCode({
|
|
@@ -128,10 +101,18 @@ export async function runIterate(opts) {
|
|
|
128
101
|
ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
|
|
129
102
|
});
|
|
130
103
|
}
|
|
104
|
+
const mergeStateResult = await handleActiveMergeState({
|
|
105
|
+
enabled: opts.merge,
|
|
106
|
+
active: activeMerge,
|
|
107
|
+
base,
|
|
108
|
+
report,
|
|
109
|
+
stallKey,
|
|
110
|
+
});
|
|
111
|
+
if (mergeStateResult)
|
|
112
|
+
return mergeStateResult;
|
|
131
113
|
const canMarkReady = report.status === "READY" &&
|
|
132
114
|
report.mergeStatus.isDraft &&
|
|
133
|
-
!report.mergeStatus.blockingBotReviewInProgress
|
|
134
|
-
!readyState.shouldCancel;
|
|
115
|
+
!report.mergeStatus.blockingBotReviewInProgress;
|
|
135
116
|
if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
|
|
136
117
|
await graphql(MARK_PR_READY_MUTATION, { pullRequestId: report.nodeId });
|
|
137
118
|
return {
|
|
@@ -141,5 +122,18 @@ export async function runIterate(opts) {
|
|
|
141
122
|
log: `MARKED READY: PR #${report.pr} converted from draft to ready for review`,
|
|
142
123
|
};
|
|
143
124
|
}
|
|
125
|
+
if (readyState.shouldCancel) {
|
|
126
|
+
await clearStallState(stallKey);
|
|
127
|
+
const mergeResult = buildReadyMergeResult(opts.merge, true, base, report);
|
|
128
|
+
if (mergeResult)
|
|
129
|
+
return mergeResult;
|
|
130
|
+
const cancelNote = blockedCancelNote(base);
|
|
131
|
+
return {
|
|
132
|
+
...base,
|
|
133
|
+
action: "cancel",
|
|
134
|
+
reason: "ready-delay-elapsed",
|
|
135
|
+
log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
144
138
|
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, { ...base, action: "wait", log: buildWaitLog(base) }, report, reviewSummaryIds);
|
|
145
139
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IterateResult, IterateResultBase, ShepherdReport } from "../../types.mts";
|
|
2
|
+
type StallKey = {
|
|
3
|
+
owner: string;
|
|
4
|
+
repo: string;
|
|
5
|
+
pr: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function buildReadyMergeResult(enabled: boolean | undefined, readyElapsed: boolean, base: IterateResultBase, report: ShepherdReport): IterateResult | null;
|
|
8
|
+
export declare function handleActiveMergeState(input: {
|
|
9
|
+
enabled: boolean | undefined;
|
|
10
|
+
active: boolean;
|
|
11
|
+
base: IterateResultBase;
|
|
12
|
+
report: ShepherdReport;
|
|
13
|
+
stallKey: StallKey;
|
|
14
|
+
}): Promise<IterateResult | null>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
2
|
+
import { buildEscalateHumanMessage, buildEscalateSuggestion } from "./escalate.mjs";
|
|
3
|
+
import { buildMergeCommandPlan } from "./merge.mjs";
|
|
4
|
+
export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
|
|
5
|
+
if (!enabled || !readyElapsed || report.mergeStatus.isDraft)
|
|
6
|
+
return null;
|
|
7
|
+
const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
|
|
8
|
+
report.mergeStatus.mergeRequirements?.mergeQueue?.enabled);
|
|
9
|
+
return {
|
|
10
|
+
...base,
|
|
11
|
+
action: "merge",
|
|
12
|
+
merge: buildMergeCommandPlan({
|
|
13
|
+
pr: report.pr,
|
|
14
|
+
repo: report.repo,
|
|
15
|
+
nodeId: report.nodeId,
|
|
16
|
+
headSha: report.headSha ?? "unknown",
|
|
17
|
+
queue,
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export async function handleActiveMergeState(input) {
|
|
22
|
+
const { enabled, active, base, report, stallKey } = input;
|
|
23
|
+
if (enabled && active) {
|
|
24
|
+
await clearStallState(stallKey);
|
|
25
|
+
return {
|
|
26
|
+
...base,
|
|
27
|
+
action: "wait",
|
|
28
|
+
log: report.mergeQueue?.inQueue
|
|
29
|
+
? `WAIT: PR #${report.pr} is in the merge queue`
|
|
30
|
+
: `WAIT: PR #${report.pr} has auto-merge enabled`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const removal = report.mergeQueue?.latestRemoval;
|
|
34
|
+
if (!enabled || !removal || report.mergeQueue?.headUpdatedAfterRemoval)
|
|
35
|
+
return null;
|
|
36
|
+
await clearStallState(stallKey);
|
|
37
|
+
const escalateBase = {
|
|
38
|
+
triggers: ["merge-queue-removed"],
|
|
39
|
+
unresolvedThreads: [],
|
|
40
|
+
ambiguousComments: [],
|
|
41
|
+
changesRequestedReviews: [],
|
|
42
|
+
mergeQueueRemoval: removal,
|
|
43
|
+
suggestion: buildEscalateSuggestion(["merge-queue-removed"], removal.reason ?? "GitHub did not provide a reason"),
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
...base,
|
|
47
|
+
action: "escalate",
|
|
48
|
+
escalate: {
|
|
49
|
+
...escalateBase,
|
|
50
|
+
humanMessage: buildEscalateHumanMessage(escalateBase, report.pr, { merge: true }),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MergeCommandPlan } from "../../types.mts";
|
|
2
|
+
interface MergePlanInput {
|
|
3
|
+
pr: number;
|
|
4
|
+
repo: string;
|
|
5
|
+
nodeId: string;
|
|
6
|
+
headSha: string;
|
|
7
|
+
queue: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function buildMergeCommandPlan(input: MergePlanInput): MergeCommandPlan;
|
|
10
|
+
export declare function renderMergeCommand(command: {
|
|
11
|
+
argv: string[];
|
|
12
|
+
}): string;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { loadConfig } from "../../config/load.mjs";
|
|
2
|
+
import { findMergeStrategies } from "../../config/merge-command-args.mjs";
|
|
3
|
+
import { renderShellCommand } from "../../cli/runner.mjs";
|
|
4
|
+
const ENQUEUE_MUTATION = "mutation EnqueuePullRequest($pullRequestId: ID!, $expectedHeadOid: GitObjectID!) { enqueuePullRequest(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid }) { mergeQueueEntry { id } } }";
|
|
5
|
+
export function buildMergeCommandPlan(input) {
|
|
6
|
+
const base = [
|
|
7
|
+
"gh",
|
|
8
|
+
"pr",
|
|
9
|
+
"merge",
|
|
10
|
+
String(input.pr),
|
|
11
|
+
"--repo",
|
|
12
|
+
input.repo,
|
|
13
|
+
"--match-head-commit",
|
|
14
|
+
input.headSha,
|
|
15
|
+
];
|
|
16
|
+
if (input.queue) {
|
|
17
|
+
return {
|
|
18
|
+
mode: "queue",
|
|
19
|
+
command: { argv: base },
|
|
20
|
+
queueApiFallbackCommand: {
|
|
21
|
+
argv: [
|
|
22
|
+
"gh",
|
|
23
|
+
"api",
|
|
24
|
+
"graphql",
|
|
25
|
+
"-f",
|
|
26
|
+
`query=${ENQUEUE_MUTATION}`,
|
|
27
|
+
"-f",
|
|
28
|
+
`pullRequestId=${input.nodeId}`,
|
|
29
|
+
"-f",
|
|
30
|
+
`expectedHeadOid=${input.headSha}`,
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const configuredArgs = loadConfig().merge?.commandArgs ?? [];
|
|
36
|
+
const hasStrategy = findMergeStrategies(configuredArgs).length > 0;
|
|
37
|
+
const commandArgs = hasStrategy ? configuredArgs : [...configuredArgs, "--merge"];
|
|
38
|
+
return {
|
|
39
|
+
mode: "auto",
|
|
40
|
+
command: { argv: [...base, "--auto", ...commandArgs] },
|
|
41
|
+
fallbackCommand: { argv: [...base, ...commandArgs] },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function renderMergeCommand(command) {
|
|
45
|
+
return renderShellCommand(command.argv);
|
|
46
|
+
}
|
package/bin/commands/poll.mjs
CHANGED
|
@@ -112,7 +112,9 @@ export async function runPoll(opts) {
|
|
|
112
112
|
await sleep(intervalMs);
|
|
113
113
|
continue;
|
|
114
114
|
}
|
|
115
|
-
if (untilTerminal
|
|
115
|
+
if ((untilTerminal || iterateOpts.merge) &&
|
|
116
|
+
lastResult.action === "mark_ready" &&
|
|
117
|
+
!pastDebounce) {
|
|
116
118
|
debounceUntil = null;
|
|
117
119
|
await sleep(intervalMs);
|
|
118
120
|
continue;
|
package/bin/config/load.d.mts
CHANGED
|
@@ -43,6 +43,10 @@ interface PrShepherdConfig {
|
|
|
43
43
|
mergeStatus: {
|
|
44
44
|
blockingReviewerLogins: string[];
|
|
45
45
|
};
|
|
46
|
+
merge?: {
|
|
47
|
+
/** Options added to ordinary `gh pr merge` commands. Queue commands never use these. */
|
|
48
|
+
commandArgs: string[];
|
|
49
|
+
};
|
|
46
50
|
actions: {
|
|
47
51
|
autoMinimizeSuppressed: boolean;
|
|
48
52
|
autoMarkReady: boolean;
|
package/bin/config/load.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { parse } from "yaml";
|
|
6
6
|
import builtins from "../config.json" with { type: "json" };
|
|
7
7
|
import { getEffectiveCwd } from "../execution-context.mjs";
|
|
8
|
+
import { findMergeStrategies } from "./merge-command-args.mjs";
|
|
8
9
|
const MINIMIZE_COMMENTS_POLICIES = ["all", "bots", "users", "none"];
|
|
9
10
|
const RC_FILENAME = ".pr-shepherdrc.yml";
|
|
10
11
|
/**
|
|
@@ -85,6 +86,35 @@ function parseNeverCancelRuns(value) {
|
|
|
85
86
|
}
|
|
86
87
|
return value;
|
|
87
88
|
}
|
|
89
|
+
const SHEPHERD_OWNED_MERGE_FLAGS = [
|
|
90
|
+
"--repo",
|
|
91
|
+
"-R",
|
|
92
|
+
"--auto",
|
|
93
|
+
"--disable-auto",
|
|
94
|
+
"--match-head-commit",
|
|
95
|
+
"--admin",
|
|
96
|
+
"--body-file",
|
|
97
|
+
"-F",
|
|
98
|
+
"--help",
|
|
99
|
+
"-h",
|
|
100
|
+
];
|
|
101
|
+
function parseMergeCommandArgs(value) {
|
|
102
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
103
|
+
throw new Error("Invalid config: merge.commandArgs must be an array of strings");
|
|
104
|
+
}
|
|
105
|
+
for (const arg of value) {
|
|
106
|
+
if (SHEPHERD_OWNED_MERGE_FLAGS.some((flag) => arg === flag ||
|
|
107
|
+
arg.startsWith(`${flag}=`) ||
|
|
108
|
+
(flag.startsWith("-") && !flag.startsWith("--") && arg.startsWith(flag)))) {
|
|
109
|
+
throw new Error(`Invalid config: merge.commandArgs cannot include Shepherd-owned ${arg}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const strategies = findMergeStrategies(value);
|
|
113
|
+
if (strategies.length > 1) {
|
|
114
|
+
throw new Error(`Invalid config: merge.commandArgs includes multiple merge strategies: ${strategies.join(", ")}`);
|
|
115
|
+
}
|
|
116
|
+
return strategies.length === 0 ? [...value, "--merge"] : [...value];
|
|
117
|
+
}
|
|
88
118
|
const KNOWN_CONFIG_KEYS = new Set([
|
|
89
119
|
"classify",
|
|
90
120
|
"botUsernames",
|
|
@@ -94,6 +124,7 @@ const KNOWN_CONFIG_KEYS = new Set([
|
|
|
94
124
|
"resolve",
|
|
95
125
|
"checks",
|
|
96
126
|
"mergeStatus",
|
|
127
|
+
"merge",
|
|
97
128
|
"actions",
|
|
98
129
|
]);
|
|
99
130
|
const KNOWN_NESTED_KEYS = {
|
|
@@ -108,6 +139,7 @@ const KNOWN_NESTED_KEYS = {
|
|
|
108
139
|
resolve: new Set(["shaPoll"]),
|
|
109
140
|
checks: new Set(["ciTriggerEvents"]),
|
|
110
141
|
mergeStatus: new Set(["blockingReviewerLogins"]),
|
|
142
|
+
merge: new Set(["commandArgs"]),
|
|
111
143
|
actions: new Set([
|
|
112
144
|
"autoMinimizeSuppressed",
|
|
113
145
|
"autoMarkReady",
|
|
@@ -194,6 +226,8 @@ export function loadConfig() {
|
|
|
194
226
|
config.botUsernames = parseBotUsernames(config.botUsernames);
|
|
195
227
|
config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
|
|
196
228
|
config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
|
|
229
|
+
if (config.merge)
|
|
230
|
+
config.merge.commandArgs = parseMergeCommandArgs(config.merge.commandArgs);
|
|
197
231
|
config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
|
|
198
232
|
configCache.set(cwd, config);
|
|
199
233
|
return config;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const MERGE_STRATEGY_FLAGS = new Map([
|
|
2
|
+
["--merge", "merge"],
|
|
3
|
+
["-m", "merge"],
|
|
4
|
+
["--squash", "squash"],
|
|
5
|
+
["-s", "squash"],
|
|
6
|
+
["--rebase", "rebase"],
|
|
7
|
+
["-r", "rebase"],
|
|
8
|
+
]);
|
|
9
|
+
/** Return active merge strategies while validating compound strategy syntax. */
|
|
10
|
+
export function findMergeStrategies(args) {
|
|
11
|
+
const strategies = [];
|
|
12
|
+
for (const arg of args) {
|
|
13
|
+
const exact = MERGE_STRATEGY_FLAGS.get(arg);
|
|
14
|
+
if (exact) {
|
|
15
|
+
strategies.push(exact);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const longAssignment = /^(--merge|--squash|--rebase)=(.*)$/.exec(arg);
|
|
19
|
+
const shortAssignment = /^(-[msr])=(.*)$/.exec(arg);
|
|
20
|
+
const assignment = longAssignment ?? shortAssignment;
|
|
21
|
+
if (assignment) {
|
|
22
|
+
if (assignment[2] !== "true" && assignment[2] !== "false") {
|
|
23
|
+
throw new Error(`Invalid config: merge.commandArgs strategy ${arg} must use true or false`);
|
|
24
|
+
}
|
|
25
|
+
if (assignment[2] === "true") {
|
|
26
|
+
strategies.push(MERGE_STRATEGY_FLAGS.get(assignment[1]));
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const shortBundle = /^-([dmrs]{2,})$/.exec(arg);
|
|
31
|
+
if (shortBundle) {
|
|
32
|
+
for (const flag of shortBundle[1]) {
|
|
33
|
+
const strategy = MERGE_STRATEGY_FLAGS.get(`-${flag}`);
|
|
34
|
+
if (strategy)
|
|
35
|
+
strategies.push(strategy);
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (/^-[dmrs].+/.test(arg)) {
|
|
40
|
+
throw new Error(`Invalid config: merge.commandArgs cannot safely parse compound short option ${arg}; use separate flags or long options`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return strategies;
|
|
44
|
+
}
|
package/bin/config.json
CHANGED
package/bin/exit-codes.d.mts
CHANGED
|
@@ -23,6 +23,8 @@ export declare const EXIT: Readonly<{
|
|
|
23
23
|
readonly ESCALATE: 13;
|
|
24
24
|
/** `cancel` + `closed` — PR closed without merging. */
|
|
25
25
|
readonly CLOSED: 14;
|
|
26
|
+
/** Agent should run the emitted merge or queue command. */
|
|
27
|
+
readonly MERGE: 15;
|
|
26
28
|
/** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
|
|
27
29
|
readonly USAGE: 64;
|
|
28
30
|
/** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
|
package/bin/exit-codes.mjs
CHANGED
|
@@ -22,6 +22,8 @@ export const EXIT = Object.freeze({
|
|
|
22
22
|
ESCALATE: 13,
|
|
23
23
|
/** `cancel` + `closed` — PR closed without merging. */
|
|
24
24
|
CLOSED: 14,
|
|
25
|
+
/** Agent should run the emitted merge or queue command. */
|
|
26
|
+
MERGE: 15,
|
|
25
27
|
/** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
|
|
26
28
|
USAGE: 64,
|
|
27
29
|
/** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
|
|
@@ -65,6 +67,8 @@ export function iterateResultToExitCode(result) {
|
|
|
65
67
|
return EXIT.FIX_CODE;
|
|
66
68
|
case "escalate":
|
|
67
69
|
return EXIT.ESCALATE;
|
|
70
|
+
case "merge":
|
|
71
|
+
return EXIT.MERGE;
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
export function errorToExitCode(err) {
|