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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -9
- package/bin/api.d.mts +12 -1
- package/bin/api.mjs +34 -1
- 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/formatters.d.mts +2 -1
- package/bin/cli/formatters.mjs +33 -0
- package/bin/cli/handlers.d.mts +1 -0
- package/bin/cli/handlers.mjs +21 -1
- package/bin/cli/help-command-pages.d.mts +22 -6
- package/bin/cli/help-command-pages.mjs +20 -4
- 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-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +3 -3
- package/bin/cli/help.d.mts +23 -7
- 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/cli/suggestion-patch-flags.d.mts +11 -0
- package/bin/cli/suggestion-patch-flags.mjs +83 -0
- package/bin/cli-parser.mjs +6 -2
- package/bin/commands/check-terminal-report.mjs +1 -0
- package/bin/commands/check.mjs +42 -1
- package/bin/commands/commit-suggestion-instruction.d.mts +1 -1
- package/bin/commands/commit-suggestion-instruction.mjs +3 -3
- package/bin/commands/commit-suggestion.d.mts +1 -1
- package/bin/commands/commit-suggestion.mjs +19 -138
- 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/commands/suggestion-patch-git.d.mts +6 -0
- package/bin/commands/suggestion-patch-git.mjs +54 -0
- package/bin/commands/suggestion-patch-item.d.mts +15 -0
- package/bin/commands/suggestion-patch-item.mjs +106 -0
- package/bin/commands/suggestion-patches.d.mts +6 -0
- package/bin/commands/suggestion-patches.mjs +108 -0
- 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/gql/{commit-suggestion-thread.gql → suggestion-threads.gql} +2 -2
- 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 +3 -1
- package/bin/github/queries.mjs +3 -1
- package/bin/github/suggestion-thread.d.mts +3 -3
- package/bin/github/suggestion-thread.mjs +8 -5
- package/bin/mcp/server.d.mts +1 -1
- package/bin/mcp/server.mjs +24 -3
- 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 -19
- package/bin/types/suggestion-patch.d.mts +30 -0
- package/bin/types/suggestion-patch.mjs +1 -0
- package/bin/types.d.mts +4 -0
- package/bin/types.mjs +4 -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 +11 -10
|
@@ -1,143 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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,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;
|
|
@@ -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;
|