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.
Files changed (82) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +8 -3
  3. package/bin/checks/classify.d.mts +3 -1
  4. package/bin/checks/classify.mjs +5 -2
  5. package/bin/cli/args.mjs +1 -0
  6. package/bin/cli/default-poll.mjs +1 -0
  7. package/bin/cli/fix-formatter.mjs +12 -2
  8. package/bin/cli/handlers.mjs +1 -0
  9. package/bin/cli/help-command-pages.d.mts +2 -2
  10. package/bin/cli/help-iterate-poll-pages.d.mts +2 -2
  11. package/bin/cli/help-iterate-poll-pages.mjs +7 -2
  12. package/bin/cli/help.d.mts +2 -2
  13. package/bin/cli/iterate-flags.d.mts +1 -0
  14. package/bin/cli/iterate-flags.mjs +2 -0
  15. package/bin/cli/iterate-formatter.mjs +4 -1
  16. package/bin/cli/iterate-instructions.mjs +15 -0
  17. package/bin/cli/iterate-lean.d.mts +1 -2
  18. package/bin/cli/iterate-lean.mjs +12 -17
  19. package/bin/cli/iterate-merge-formatter.d.mts +3 -0
  20. package/bin/cli/iterate-merge-formatter.mjs +52 -0
  21. package/bin/cli/iterate-verbose.d.mts +6 -0
  22. package/bin/cli/iterate-verbose.mjs +14 -0
  23. package/bin/cli/poll-handler.mjs +1 -0
  24. package/bin/cli/runner.mjs +4 -3
  25. package/bin/commands/check-terminal-report.mjs +1 -0
  26. package/bin/commands/check.mjs +42 -1
  27. package/bin/commands/iterate/base.d.mts +5 -0
  28. package/bin/commands/iterate/base.mjs +25 -0
  29. package/bin/commands/iterate/escalate.d.mts +3 -1
  30. package/bin/commands/iterate/escalate.mjs +18 -2
  31. package/bin/commands/iterate/fix-code.mjs +16 -0
  32. package/bin/commands/iterate/helpers.mjs +6 -2
  33. package/bin/commands/iterate/index.mjs +29 -35
  34. package/bin/commands/iterate/merge-state.d.mts +15 -0
  35. package/bin/commands/iterate/merge-state.mjs +53 -0
  36. package/bin/commands/iterate/merge.d.mts +13 -0
  37. package/bin/commands/iterate/merge.mjs +46 -0
  38. package/bin/commands/poll.mjs +3 -1
  39. package/bin/config/load.d.mts +4 -0
  40. package/bin/config/load.mjs +34 -0
  41. package/bin/config/merge-command-args.d.mts +2 -0
  42. package/bin/config/merge-command-args.mjs +44 -0
  43. package/bin/config.json +3 -0
  44. package/bin/exit-codes.d.mts +2 -0
  45. package/bin/exit-codes.mjs +4 -0
  46. package/bin/github/batch-parse-checks.d.mts +3 -0
  47. package/bin/github/batch-parse-checks.mjs +29 -0
  48. package/bin/github/batch-parsers-rules.d.mts +3 -1
  49. package/bin/github/batch-parsers-rules.mjs +33 -0
  50. package/bin/github/batch-parsers.mjs +23 -26
  51. package/bin/github/batch-raw-rules.d.mts +48 -0
  52. package/bin/github/batch-raw-types.mjs +0 -1
  53. package/bin/github/batch.mjs +2 -0
  54. package/bin/github/gql/batch-pr.gql +92 -0
  55. package/bin/github/gql/commit-check-contexts.gql +58 -0
  56. package/bin/github/merge-queue-checks.d.mts +4 -0
  57. package/bin/github/merge-queue-checks.mjs +48 -0
  58. package/bin/github/queries.d.mts +2 -0
  59. package/bin/github/queries.mjs +2 -0
  60. package/bin/mcp/server.mjs +1 -0
  61. package/bin/reporters/agent.mjs +6 -3
  62. package/bin/types/activity.d.mts +2 -0
  63. package/bin/types/escalate.d.mts +30 -0
  64. package/bin/types/escalate.mjs +1 -0
  65. package/bin/types/github.d.mts +8 -0
  66. package/bin/types/github.mjs +0 -3
  67. package/bin/types/iterate.d.mts +14 -31
  68. package/bin/types/merge-action.d.mts +12 -0
  69. package/bin/types/merge-action.mjs +1 -0
  70. package/bin/types/merge-queue.d.mts +13 -0
  71. package/bin/types/merge-queue.mjs +1 -0
  72. package/bin/types/merge-requirements.d.mts +17 -0
  73. package/bin/types/report.d.mts +8 -0
  74. package/bin/types.d.mts +3 -0
  75. package/bin/types.mjs +3 -0
  76. package/bin/util/markdown.d.mts +2 -0
  77. package/bin/util/markdown.mjs +7 -0
  78. package/package.json +1 -1
  79. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  80. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  81. package/plugins/pr-shepherd/.mcp.json +1 -1
  82. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
@@ -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 classifiedChecks = classifyChecks(allChecks);
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,5 @@
1
+ import type { IterateResultBase, ShepherdReport } from "../../types.mts";
2
+ export declare function buildIterateBase(report: ShepherdReport, readyState: {
3
+ shouldCancel: boolean;
4
+ remainingSeconds: number;
5
+ }): IterateResultBase;
@@ -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): string;
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, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, blockedCancelNote, } from "./helpers.mjs";
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 isCleanReadyHandoff = report.status === "READY" && !hasActionableWork;
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
+ }
@@ -112,7 +112,9 @@ export async function runPoll(opts) {
112
112
  await sleep(intervalMs);
113
113
  continue;
114
114
  }
115
- if (untilTerminal && lastResult.action === "mark_ready" && !pastDebounce) {
115
+ if ((untilTerminal || iterateOpts.merge) &&
116
+ lastResult.action === "mark_ready" &&
117
+ !pastDebounce) {
116
118
  debounceUntil = null;
117
119
  await sleep(intervalMs);
118
120
  continue;
@@ -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;
@@ -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,2 @@
1
+ /** Return active merge strategies while validating compound strategy syntax. */
2
+ export declare function findMergeStrategies(args: string[]): string[];
@@ -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
@@ -36,6 +36,9 @@
36
36
  "mergeStatus": {
37
37
  "blockingReviewerLogins": ["copilot"]
38
38
  },
39
+ "merge": {
40
+ "commandArgs": []
41
+ },
39
42
  "actions": {
40
43
  "autoMinimizeSuppressed": true,
41
44
  "autoMarkReady": true,
@@ -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. */
@@ -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) {
@@ -0,0 +1,3 @@
1
+ import type { CheckRun } from "../types.mts";
2
+ import type { RawContextNode } from "./batch-raw-types.mts";
3
+ export declare function parseCheckNodes(nodes: Array<RawContextNode | null> | undefined, mergeCommitOid?: string): CheckRun[];