pr-shepherd 0.41.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 (109) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -9
  3. package/bin/api.d.mts +12 -1
  4. package/bin/api.mjs +34 -1
  5. package/bin/checks/classify.d.mts +3 -1
  6. package/bin/checks/classify.mjs +5 -2
  7. package/bin/cli/args.mjs +1 -0
  8. package/bin/cli/default-poll.mjs +1 -0
  9. package/bin/cli/fix-formatter.mjs +12 -2
  10. package/bin/cli/formatters.d.mts +2 -1
  11. package/bin/cli/formatters.mjs +33 -0
  12. package/bin/cli/handlers.d.mts +1 -0
  13. package/bin/cli/handlers.mjs +21 -1
  14. package/bin/cli/help-command-pages.d.mts +22 -6
  15. package/bin/cli/help-command-pages.mjs +20 -4
  16. package/bin/cli/help-iterate-poll-pages.d.mts +2 -2
  17. package/bin/cli/help-iterate-poll-pages.mjs +7 -2
  18. package/bin/cli/help-top-page.d.mts +1 -1
  19. package/bin/cli/help-top-page.mjs +3 -3
  20. package/bin/cli/help.d.mts +23 -7
  21. package/bin/cli/iterate-flags.d.mts +1 -0
  22. package/bin/cli/iterate-flags.mjs +2 -0
  23. package/bin/cli/iterate-formatter.mjs +4 -1
  24. package/bin/cli/iterate-instructions.mjs +15 -0
  25. package/bin/cli/iterate-lean.d.mts +1 -2
  26. package/bin/cli/iterate-lean.mjs +12 -17
  27. package/bin/cli/iterate-merge-formatter.d.mts +3 -0
  28. package/bin/cli/iterate-merge-formatter.mjs +52 -0
  29. package/bin/cli/iterate-verbose.d.mts +6 -0
  30. package/bin/cli/iterate-verbose.mjs +14 -0
  31. package/bin/cli/poll-handler.mjs +1 -0
  32. package/bin/cli/runner.mjs +4 -3
  33. package/bin/cli/suggestion-patch-flags.d.mts +11 -0
  34. package/bin/cli/suggestion-patch-flags.mjs +83 -0
  35. package/bin/cli-parser.mjs +6 -2
  36. package/bin/commands/check-terminal-report.mjs +1 -0
  37. package/bin/commands/check.mjs +42 -1
  38. package/bin/commands/commit-suggestion-instruction.d.mts +1 -1
  39. package/bin/commands/commit-suggestion-instruction.mjs +3 -3
  40. package/bin/commands/commit-suggestion.d.mts +1 -1
  41. package/bin/commands/commit-suggestion.mjs +19 -138
  42. package/bin/commands/iterate/base.d.mts +5 -0
  43. package/bin/commands/iterate/base.mjs +25 -0
  44. package/bin/commands/iterate/escalate.d.mts +3 -1
  45. package/bin/commands/iterate/escalate.mjs +18 -2
  46. package/bin/commands/iterate/fix-code.mjs +16 -0
  47. package/bin/commands/iterate/helpers.mjs +6 -2
  48. package/bin/commands/iterate/index.mjs +29 -35
  49. package/bin/commands/iterate/merge-state.d.mts +15 -0
  50. package/bin/commands/iterate/merge-state.mjs +53 -0
  51. package/bin/commands/iterate/merge.d.mts +13 -0
  52. package/bin/commands/iterate/merge.mjs +46 -0
  53. package/bin/commands/poll.mjs +3 -1
  54. package/bin/commands/suggestion-patch-git.d.mts +6 -0
  55. package/bin/commands/suggestion-patch-git.mjs +54 -0
  56. package/bin/commands/suggestion-patch-item.d.mts +15 -0
  57. package/bin/commands/suggestion-patch-item.mjs +106 -0
  58. package/bin/commands/suggestion-patches.d.mts +6 -0
  59. package/bin/commands/suggestion-patches.mjs +108 -0
  60. package/bin/config/load.d.mts +4 -0
  61. package/bin/config/load.mjs +34 -0
  62. package/bin/config/merge-command-args.d.mts +2 -0
  63. package/bin/config/merge-command-args.mjs +44 -0
  64. package/bin/config.json +3 -0
  65. package/bin/exit-codes.d.mts +2 -0
  66. package/bin/exit-codes.mjs +4 -0
  67. package/bin/github/batch-parse-checks.d.mts +3 -0
  68. package/bin/github/batch-parse-checks.mjs +29 -0
  69. package/bin/github/batch-parsers-rules.d.mts +3 -1
  70. package/bin/github/batch-parsers-rules.mjs +33 -0
  71. package/bin/github/batch-parsers.mjs +23 -26
  72. package/bin/github/batch-raw-rules.d.mts +48 -0
  73. package/bin/github/batch-raw-types.mjs +0 -1
  74. package/bin/github/batch.mjs +2 -0
  75. package/bin/github/gql/batch-pr.gql +92 -0
  76. package/bin/github/gql/commit-check-contexts.gql +58 -0
  77. package/bin/github/gql/{commit-suggestion-thread.gql → suggestion-threads.gql} +2 -2
  78. package/bin/github/merge-queue-checks.d.mts +4 -0
  79. package/bin/github/merge-queue-checks.mjs +48 -0
  80. package/bin/github/queries.d.mts +3 -1
  81. package/bin/github/queries.mjs +3 -1
  82. package/bin/github/suggestion-thread.d.mts +3 -3
  83. package/bin/github/suggestion-thread.mjs +8 -5
  84. package/bin/mcp/server.d.mts +1 -1
  85. package/bin/mcp/server.mjs +24 -3
  86. package/bin/reporters/agent.mjs +6 -3
  87. package/bin/types/activity.d.mts +2 -0
  88. package/bin/types/escalate.d.mts +30 -0
  89. package/bin/types/escalate.mjs +1 -0
  90. package/bin/types/github.d.mts +8 -0
  91. package/bin/types/github.mjs +0 -3
  92. package/bin/types/iterate.d.mts +14 -31
  93. package/bin/types/merge-action.d.mts +12 -0
  94. package/bin/types/merge-action.mjs +1 -0
  95. package/bin/types/merge-queue.d.mts +13 -0
  96. package/bin/types/merge-queue.mjs +1 -0
  97. package/bin/types/merge-requirements.d.mts +17 -0
  98. package/bin/types/report.d.mts +8 -19
  99. package/bin/types/suggestion-patch.d.mts +30 -0
  100. package/bin/types/suggestion-patch.mjs +1 -0
  101. package/bin/types.d.mts +4 -0
  102. package/bin/types.mjs +4 -0
  103. package/bin/util/markdown.d.mts +2 -0
  104. package/bin/util/markdown.mjs +7 -0
  105. package/package.json +1 -1
  106. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  107. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  108. package/plugins/pr-shepherd/.mcp.json +1 -1
  109. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +11 -10
@@ -1,143 +1,24 @@
1
- import { execFile as execFileCb } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
3
- import { relative, resolve } from "node:path";
4
- import { promisify } from "node:util";
5
- import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/client.mjs";
6
- import { fetchSuggestionThread } from "../github/suggestion-thread.mjs";
7
- import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
8
- import { buildUnifiedDiff } from "../suggestions/patch.mjs";
9
- import { getUnsafeSuggestionRangeReason } from "../suggestions/range.mjs";
10
- import { EXIT, ShepherdError } from "../exit-codes.mjs";
11
- import { buildPrShepherdCommand } from "../cli/runner.mjs";
12
- import { getEffectiveCwd, getExecutionCwd } from "../execution-context.mjs";
13
- const execFile = promisify(execFileCb);
14
- /** @deprecated Hidden implementation for `commit-suggestion`; use `build-suggestion-patch`. */
1
+ import { buildSingularInstructions } from "./suggestion-patch-item.mjs";
2
+ import { runSuggestionPatches } from "./suggestion-patches.mjs";
3
+ /** @deprecated Use runSuggestionPatches. */
15
4
  export async function runCommitSuggestion(opts) {
16
- if (!opts.threadId) {
17
- throw new ShepherdError("--thread-id is required", EXIT.USAGE);
18
- }
19
- if (!opts.message || opts.message.trim() === "") {
20
- throw new ShepherdError("--message is required and must be non-empty", EXIT.USAGE);
21
- }
22
- const repo = await getRepoInfo();
23
- const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
24
- if (prNumber === null) {
25
- throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
26
- }
27
- const currentBranch = await getCurrentBranch();
28
- const { stdout: localHeadOut } = await execFile("git", ["rev-parse", "HEAD"], {
29
- cwd: getExecutionCwd(),
5
+ const result = await runSuggestionPatches({
6
+ prNumber: opts.prNumber,
7
+ format: opts.format,
8
+ verbose: opts.verbose,
9
+ suggestions: [
10
+ {
11
+ threadId: opts.threadId,
12
+ message: opts.message,
13
+ ...(opts.description !== undefined && { description: opts.description }),
14
+ },
15
+ ],
30
16
  });
31
- const localHeadSha = localHeadOut.trim();
32
- const data = await fetchSuggestionThread(prNumber, repo, opts.threadId);
33
- if (!data.headRepoWithOwner) {
34
- throw new ShepherdError(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`, EXIT.UNAVAILABLE);
35
- }
36
- if (currentBranch !== data.headRefName) {
37
- throw new ShepherdError(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
38
- `Check out "${data.headRefName}" before applying suggestions.`, EXIT.UNAVAILABLE);
39
- }
40
- if (localHeadSha !== data.headRefOid) {
41
- throw new ShepherdError(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
42
- `Pull/rebase "${data.headRefName}" to the latest PR head and try again.`, EXIT.UNAVAILABLE);
43
- }
44
- const thread = data.thread;
45
- if (!thread) {
46
- throw new ShepherdError(`Thread ${opts.threadId} not found on PR #${prNumber}.`, EXIT.UNAVAILABLE);
47
- }
48
- if (thread.isResolved) {
49
- throw new ShepherdError(`Thread ${opts.threadId} is already resolved.`, EXIT.UNAVAILABLE);
50
- }
51
- if (thread.isOutdated) {
52
- throw new ShepherdError(`Thread ${opts.threadId} is outdated.`, EXIT.UNAVAILABLE);
53
- }
54
- if (thread.isMinimized) {
55
- throw new ShepherdError(`Thread ${opts.threadId} is minimized.`, EXIT.UNAVAILABLE);
56
- }
57
- if (!thread.path || thread.line === null) {
58
- throw new ShepherdError(`Thread ${opts.threadId} has no file/line anchor.`, EXIT.UNAVAILABLE);
59
- }
60
- // Validate the target file is clean before generating the patch, so the emitted
61
- // `git add -- <file>` instruction cannot accidentally stage unrelated local edits.
62
- const { stdout: fileStatus } = await execFile("git", ["status", "--porcelain", "--", thread.path], { cwd: getExecutionCwd() });
63
- if (fileStatus.trim() !== "") {
64
- throw new ShepherdError(`${thread.path} has uncommitted changes. Commit or stash them before running build-suggestion-patch.`, EXIT.UNAVAILABLE);
65
- }
66
- const parsed = parseSuggestion(thread.body);
67
- if (!parsed) {
68
- throw new ShepherdError(`Thread ${opts.threadId} has no suggestion block in the comment body.`, EXIT.UNAVAILABLE);
69
- }
70
- if (!isCommittableSuggestion(parsed)) {
71
- throw new ShepherdError(`Thread ${opts.threadId}'s suggestion body contains nested suggestion fencing or unbalanced ` +
72
- `3+ backtick fences — refusing to apply (could silently truncate).`, EXIT.UNAVAILABLE);
73
- }
74
- const startLine = thread.startLine ?? thread.line;
75
- const endLine = thread.line;
76
- const filePath = thread.path;
77
- const cwd = getEffectiveCwd();
78
- const resolvedPath = resolve(cwd, filePath);
79
- const rel = relative(cwd, resolvedPath);
80
- if (rel.startsWith("..") || rel === "") {
81
- throw new ShepherdError(`Thread ${opts.threadId} path escapes the working tree.`, EXIT.UNAVAILABLE);
82
- }
83
- const originalContent = await readFile(resolvedPath, "utf8");
84
- const unsafeRangeReason = getUnsafeSuggestionRangeReason({
85
- originalContent,
86
- startLine,
87
- endLine,
88
- replacementLines: parsed.lines,
89
- });
90
- if (unsafeRangeReason) {
91
- const range = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
92
- throw new ShepherdError(`Thread ${opts.threadId}'s suggestion does not safely fit GitHub's anchored range ` +
93
- `${filePath}:${range}: ${unsafeRangeReason} Refusing to build a patch; inspect the surrounding ` +
94
- `source and reviewer intent, then apply the change manually.`, EXIT.UNAVAILABLE);
95
- }
96
- const patch = buildUnifiedDiff({
97
- path: filePath,
98
- originalContent,
99
- startLine,
100
- endLine,
101
- replacementLines: parsed.lines,
102
- });
103
- const coAuthor = `Co-authored-by: ${thread.author} <${thread.author}@users.noreply.github.com>`;
104
- const commitBody = opts.description ? `${opts.description}\n\n${coAuthor}` : coAuthor;
105
- const commitMessageArg = opts.message;
106
- const commitBodyArg = commitBody;
107
- const quotedPath = `'${filePath.replace(/'/g, "'\\''")}'`;
108
- const range = startLine === endLine ? `line ${startLine}` : `lines ${startLine}–${endLine}`;
109
- const sq = (s) => `'${s.replace(/'/g, "'\\''")}'`;
110
- const commitCmd = [
111
- "git commit",
112
- `-m ${sq(commitMessageArg)}`,
113
- ...commitBodyArg.split("\n\n").map((p) => `-m ${sq(p)}`),
114
- ].join(" ");
115
- const resolveCommand = buildPrShepherdCommand([
116
- "apply",
117
- "review",
118
- String(prNumber),
119
- "--resolve-thread-ids",
120
- opts.threadId,
121
- ]).text;
122
- const postActionInstructions = [
123
- `Apply the patch to \`${filePath}\`: run \`git apply\` with the diff shown above, or edit the file directly using the line range (${range}).`,
124
- `Stage the file: \`git add -- ${quotedPath}\``,
125
- `Commit: \`${commitCmd}\``,
126
- `Resolve the thread on GitHub: \`${resolveCommand}\``,
127
- `Push when ready: \`git push\` (or \`git push --force-with-lease\` after rebasing).`,
128
- ];
17
+ const patch = result.patches[0];
129
18
  return {
130
- pr: prNumber,
131
- repo: `${repo.owner}/${repo.name}`,
132
- threadId: opts.threadId,
133
- path: filePath,
134
- startLine,
135
- endLine,
136
- author: thread.author,
137
- patch,
138
- commitMessage: commitMessageArg,
139
- commitBody: commitBodyArg,
140
- filesToStage: [filePath],
141
- postActionInstructions,
19
+ ...patch,
20
+ pr: result.pr,
21
+ repo: result.repo,
22
+ postActionInstructions: buildSingularInstructions(patch, result.pr),
142
23
  };
143
24
  }
@@ -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;
@@ -0,0 +1,6 @@
1
+ export declare function getLocalHeadSha(): Promise<string>;
2
+ export declare function isAncestor(ancestor: string, descendant: string): Promise<boolean>;
3
+ export declare function readPrHeadFile(headSha: string, path: string): Promise<string>;
4
+ export declare function getPathsStatus(paths: readonly string[]): Promise<string>;
5
+ /** Dry-run an ordered patch stream against the current worktree. */
6
+ export declare function checkPatchesApply(patches: readonly string[]): Promise<void>;
@@ -0,0 +1,54 @@
1
+ import { execFile as execFileCb } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { getExecutionCwd } from "../execution-context.mjs";
4
+ const execFile = promisify(execFileCb);
5
+ export async function getLocalHeadSha() {
6
+ const { stdout } = await execFile("git", ["rev-parse", "HEAD"], {
7
+ cwd: getExecutionCwd(),
8
+ });
9
+ return stdout.trim();
10
+ }
11
+ export async function isAncestor(ancestor, descendant) {
12
+ try {
13
+ await execFile("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
14
+ cwd: getExecutionCwd(),
15
+ });
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ if (isExitCode(error, 1) || isExitCode(error, 128))
20
+ return false;
21
+ throw error;
22
+ }
23
+ }
24
+ export async function readPrHeadFile(headSha, path) {
25
+ const { stdout } = await execFile("git", ["show", `${headSha}:${path}`], {
26
+ cwd: getExecutionCwd(),
27
+ maxBuffer: 32 * 1024 * 1024,
28
+ });
29
+ return stdout;
30
+ }
31
+ export async function getPathsStatus(paths) {
32
+ const { stdout } = await execFile("git", ["status", "--porcelain", "--", ...paths], {
33
+ cwd: getExecutionCwd(),
34
+ });
35
+ return stdout.trim();
36
+ }
37
+ /** Dry-run an ordered patch stream against the current worktree. */
38
+ export function checkPatchesApply(patches) {
39
+ const input = patches.join("\n");
40
+ return new Promise((resolve, reject) => {
41
+ const child = execFileCb("git", // NOSONAR -- resolve git through PATH consistently with all CLI git helpers.
42
+ ["apply", "--check"], { cwd: getExecutionCwd() }, (error, _stdout, stderr) => {
43
+ if (!error) {
44
+ resolve();
45
+ return;
46
+ }
47
+ reject(new Error(stderr.trim() || error.message));
48
+ });
49
+ child.stdin.end(input);
50
+ });
51
+ }
52
+ function isExitCode(error, code) {
53
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
54
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReviewThread, SuggestionPatchResult } from "../types.mts";
2
+ export interface SuggestionPatchRequest {
3
+ threadId: string;
4
+ message: string;
5
+ description?: string;
6
+ }
7
+ export declare function validateSuggestionThread(thread: ReviewThread | null, request: SuggestionPatchRequest): ReviewThread;
8
+ export declare function buildSuggestionPatchItem({ thread, request, originalContent, }: {
9
+ thread: ReviewThread;
10
+ request: SuggestionPatchRequest;
11
+ originalContent: string;
12
+ }): SuggestionPatchResult;
13
+ export declare function buildSingularInstructions(patch: SuggestionPatchResult, prNumber: number): string[];
14
+ export declare function buildCommitCommand(patch: SuggestionPatchResult): string;
15
+ export declare function quotePath(path: string): string;