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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import { getEffectiveCwd } from "../execution-context.mjs";
|
|
3
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
4
|
+
import { buildPrShepherdCommand } from "../cli/runner.mjs";
|
|
5
|
+
import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
|
|
6
|
+
import { buildUnifiedDiff } from "../suggestions/patch.mjs";
|
|
7
|
+
import { getUnsafeSuggestionRangeReason } from "../suggestions/range.mjs";
|
|
8
|
+
const SINGLE_QUOTE_ESCAPE = String.raw `'\''`;
|
|
9
|
+
export function validateSuggestionThread(thread, request) {
|
|
10
|
+
if (!thread)
|
|
11
|
+
unavailable(`Thread ${request.threadId} not found on PR.`);
|
|
12
|
+
if (thread.isResolved)
|
|
13
|
+
unavailable(`Thread ${request.threadId} is already resolved.`);
|
|
14
|
+
if (thread.isOutdated)
|
|
15
|
+
unavailable(`Thread ${request.threadId} is outdated.`);
|
|
16
|
+
if (thread.isMinimized)
|
|
17
|
+
unavailable(`Thread ${request.threadId} is minimized.`);
|
|
18
|
+
if (!thread.path || thread.line === null) {
|
|
19
|
+
unavailable(`Thread ${request.threadId} has no file/line anchor.`);
|
|
20
|
+
}
|
|
21
|
+
ensureSafePath(thread.path);
|
|
22
|
+
return thread;
|
|
23
|
+
}
|
|
24
|
+
export function buildSuggestionPatchItem({ thread, request, originalContent, }) {
|
|
25
|
+
const parsed = parseSuggestion(thread.body);
|
|
26
|
+
if (!parsed)
|
|
27
|
+
unavailable(`Thread ${request.threadId} has no suggestion block in the comment body.`);
|
|
28
|
+
if (!isCommittableSuggestion(parsed)) {
|
|
29
|
+
unavailable(`Thread ${request.threadId}'s suggestion body contains nested suggestion fencing or unbalanced ` +
|
|
30
|
+
`3+ backtick fences — refusing to apply (could silently truncate).`);
|
|
31
|
+
}
|
|
32
|
+
const startLine = thread.startLine ?? thread.line;
|
|
33
|
+
const endLine = thread.line;
|
|
34
|
+
const unsafeRangeReason = getUnsafeSuggestionRangeReason({
|
|
35
|
+
originalContent,
|
|
36
|
+
startLine,
|
|
37
|
+
endLine,
|
|
38
|
+
replacementLines: parsed.lines,
|
|
39
|
+
});
|
|
40
|
+
if (unsafeRangeReason) {
|
|
41
|
+
const range = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
|
|
42
|
+
unavailable(`Thread ${request.threadId}'s suggestion does not safely fit GitHub's anchored range ` +
|
|
43
|
+
`${thread.path}:${range}: ${unsafeRangeReason} Refusing to build a patch; inspect the ` +
|
|
44
|
+
`surrounding source and reviewer intent, then apply the change manually.`);
|
|
45
|
+
}
|
|
46
|
+
const coAuthor = `Co-authored-by: ${thread.author} <${thread.author}@users.noreply.github.com>`;
|
|
47
|
+
return {
|
|
48
|
+
threadId: request.threadId,
|
|
49
|
+
path: thread.path,
|
|
50
|
+
startLine,
|
|
51
|
+
endLine,
|
|
52
|
+
author: thread.author,
|
|
53
|
+
patch: buildUnifiedDiff({
|
|
54
|
+
path: thread.path,
|
|
55
|
+
originalContent,
|
|
56
|
+
startLine,
|
|
57
|
+
endLine,
|
|
58
|
+
replacementLines: parsed.lines,
|
|
59
|
+
}),
|
|
60
|
+
commitMessage: request.message,
|
|
61
|
+
commitBody: request.description ? `${request.description}\n\n${coAuthor}` : coAuthor,
|
|
62
|
+
filesToStage: [thread.path],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function buildSingularInstructions(patch, prNumber) {
|
|
66
|
+
const range = patch.startLine === patch.endLine
|
|
67
|
+
? `line ${patch.startLine}`
|
|
68
|
+
: `lines ${patch.startLine}–${patch.endLine}`;
|
|
69
|
+
const resolveCommand = buildPrShepherdCommand([
|
|
70
|
+
"apply",
|
|
71
|
+
"review",
|
|
72
|
+
String(prNumber),
|
|
73
|
+
"--resolve-thread-ids",
|
|
74
|
+
patch.threadId,
|
|
75
|
+
]).text;
|
|
76
|
+
return [
|
|
77
|
+
`Apply the patch to \`${patch.path}\`: run \`git apply\` with the diff shown above, or edit the file directly using the line range (${range}).`,
|
|
78
|
+
`Stage the file: \`git add -- ${quotePath(patch.path)}\``,
|
|
79
|
+
`Commit: \`${buildCommitCommand(patch)}\``,
|
|
80
|
+
`Resolve the thread on GitHub: \`${resolveCommand}\``,
|
|
81
|
+
`Push when ready: \`git push\` (or \`git push --force-with-lease\` after rebasing).`,
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
export function buildCommitCommand(patch) {
|
|
85
|
+
return [
|
|
86
|
+
"git commit",
|
|
87
|
+
`-m ${shellQuote(patch.commitMessage)}`,
|
|
88
|
+
...patch.commitBody.split("\n\n").map((part) => `-m ${shellQuote(part)}`),
|
|
89
|
+
].join(" ");
|
|
90
|
+
}
|
|
91
|
+
export function quotePath(path) {
|
|
92
|
+
return shellQuote(path);
|
|
93
|
+
}
|
|
94
|
+
function shellQuote(value) {
|
|
95
|
+
return "'" + value.replaceAll("'", SINGLE_QUOTE_ESCAPE) + "'";
|
|
96
|
+
}
|
|
97
|
+
function ensureSafePath(path) {
|
|
98
|
+
const cwd = getEffectiveCwd();
|
|
99
|
+
const rel = relative(cwd, resolve(cwd, path));
|
|
100
|
+
if (rel.startsWith("..") || rel === "") {
|
|
101
|
+
unavailable(`Thread path escapes the working tree: ${path}.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function unavailable(message) {
|
|
105
|
+
throw new ShepherdError(message, EXIT.UNAVAILABLE);
|
|
106
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BuildSuggestionPatchesResult, GlobalOptions } from "../types.mts";
|
|
2
|
+
import { type SuggestionPatchRequest } from "./suggestion-patch-item.mts";
|
|
3
|
+
export interface SuggestionPatchesOptions extends GlobalOptions {
|
|
4
|
+
suggestions: readonly SuggestionPatchRequest[];
|
|
5
|
+
}
|
|
6
|
+
export declare function runSuggestionPatches(opts: SuggestionPatchesOptions): Promise<BuildSuggestionPatchesResult>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/client.mjs";
|
|
2
|
+
import { fetchSuggestionThreads } from "../github/suggestion-thread.mjs";
|
|
3
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
4
|
+
import { buildCommitCommand, buildSuggestionPatchItem, quotePath, validateSuggestionThread, } from "./suggestion-patch-item.mjs";
|
|
5
|
+
import { checkPatchesApply, getLocalHeadSha, getPathsStatus, isAncestor, readPrHeadFile, } from "./suggestion-patch-git.mjs";
|
|
6
|
+
export async function runSuggestionPatches(opts) {
|
|
7
|
+
validateRequests(opts.suggestions);
|
|
8
|
+
const repo = await getRepoInfo();
|
|
9
|
+
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
10
|
+
if (prNumber === null) {
|
|
11
|
+
throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
|
|
12
|
+
}
|
|
13
|
+
const [currentBranch, localHeadSha, data] = await Promise.all([
|
|
14
|
+
getCurrentBranch(),
|
|
15
|
+
getLocalHeadSha(),
|
|
16
|
+
fetchSuggestionThreads(prNumber, repo, opts.suggestions.map((suggestion) => suggestion.threadId)),
|
|
17
|
+
]);
|
|
18
|
+
await validateHead({ currentBranch, localHeadSha, prNumber, data });
|
|
19
|
+
const threads = data.threads.map((thread, index) => validateSuggestionThread(thread, opts.suggestions[index]));
|
|
20
|
+
const paths = [...new Set(threads.map((thread) => thread.path))];
|
|
21
|
+
const status = await getPathsStatus(paths);
|
|
22
|
+
if (status !== "") {
|
|
23
|
+
throw new ShepherdError(`Suggestion target files have uncommitted changes:\n${status}\n` +
|
|
24
|
+
"Commit or stash them before running build-suggestion-patches.", EXIT.UNAVAILABLE);
|
|
25
|
+
}
|
|
26
|
+
const originals = await readOriginals(data.headRefOid, paths);
|
|
27
|
+
const patches = threads.map((thread, index) => buildSuggestionPatchItem({
|
|
28
|
+
thread,
|
|
29
|
+
request: opts.suggestions[index],
|
|
30
|
+
originalContent: originals.get(thread.path),
|
|
31
|
+
}));
|
|
32
|
+
try {
|
|
33
|
+
await checkPatchesApply(patches.map((patch) => patch.patch));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
throw new ShepherdError(`Ordered suggestion patches built from PR head ${data.headRefOid} do not apply to ` +
|
|
37
|
+
`local HEAD ${localHeadSha}: ${errorMessage(error)} No patches were returned; inspect the ` +
|
|
38
|
+
"current source and reviewer intent.", EXIT.UNAVAILABLE);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
pr: prNumber,
|
|
42
|
+
repo: `${repo.owner}/${repo.name}`,
|
|
43
|
+
patches,
|
|
44
|
+
postActionInstructions: buildBatchInstructions(patches),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function validateRequests(requests) {
|
|
48
|
+
if (requests.length === 0)
|
|
49
|
+
usage("At least one suggestion is required.");
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
for (const request of requests) {
|
|
52
|
+
if (!request.threadId || request.threadId.trim() === "")
|
|
53
|
+
usage("Each --thread-id is required.");
|
|
54
|
+
if (!request.message || request.message.trim() === "") {
|
|
55
|
+
usage(`--message is required and must be non-empty for thread ${request.threadId}.`);
|
|
56
|
+
}
|
|
57
|
+
if (seen.has(request.threadId))
|
|
58
|
+
usage(`Duplicate thread ID: ${request.threadId}.`);
|
|
59
|
+
seen.add(request.threadId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function validateHead({ currentBranch, localHeadSha, prNumber, data, }) {
|
|
63
|
+
if (!data.headRepoWithOwner) {
|
|
64
|
+
throw new ShepherdError(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`, EXIT.UNAVAILABLE);
|
|
65
|
+
}
|
|
66
|
+
if (currentBranch !== data.headRefName) {
|
|
67
|
+
throw new ShepherdError(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
|
|
68
|
+
`Check out "${data.headRefName}" before applying suggestions.`, EXIT.UNAVAILABLE);
|
|
69
|
+
}
|
|
70
|
+
if (localHeadSha === data.headRefOid)
|
|
71
|
+
return;
|
|
72
|
+
await ensureDescendant(data.headRefOid, localHeadSha, data.headRefName);
|
|
73
|
+
}
|
|
74
|
+
async function ensureDescendant(prHeadSha, localHeadSha, branch) {
|
|
75
|
+
if (await isAncestor(prHeadSha, localHeadSha))
|
|
76
|
+
return;
|
|
77
|
+
throw new ShepherdError(`Local HEAD ${localHeadSha} is not PR head ${prHeadSha} or its descendant. ` +
|
|
78
|
+
`Pull/rebase "${branch}" to the latest PR head and try again.`, EXIT.UNAVAILABLE);
|
|
79
|
+
}
|
|
80
|
+
async function readOriginals(headSha, paths) {
|
|
81
|
+
const entries = await Promise.all(paths.map(async (path) => {
|
|
82
|
+
try {
|
|
83
|
+
return [path, await readPrHeadFile(headSha, path)];
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
throw new ShepherdError(`Could not read ${path} at PR head ${headSha}: ${errorMessage(error)}`, EXIT.UNAVAILABLE);
|
|
87
|
+
}
|
|
88
|
+
}));
|
|
89
|
+
return new Map(entries);
|
|
90
|
+
}
|
|
91
|
+
function buildBatchInstructions(patches) {
|
|
92
|
+
const patchInstructions = patches.flatMap((patch, index) => [
|
|
93
|
+
`Apply patch ${index + 1} to \`${patch.path}\` using \`git apply\`.`,
|
|
94
|
+
`Stage patch ${index + 1}: \`git add -- ${quotePath(patch.path)}\``,
|
|
95
|
+
`Commit patch ${index + 1}: \`${buildCommitCommand(patch)}\``,
|
|
96
|
+
]);
|
|
97
|
+
return [
|
|
98
|
+
...patchInstructions,
|
|
99
|
+
"Push once after every patch is committed: `git push` (or `git push --force-with-lease` after rebasing).",
|
|
100
|
+
`Then continue with the originating iterate output's \`apply review\` instructions for thread IDs: ${patches.map((patch) => patch.threadId).join(", ")}.`,
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
function errorMessage(error) {
|
|
104
|
+
return error instanceof Error ? error.message : String(error);
|
|
105
|
+
}
|
|
106
|
+
function usage(message) {
|
|
107
|
+
throw new ShepherdError(message, EXIT.USAGE);
|
|
108
|
+
}
|
package/bin/config/load.d.mts
CHANGED
|
@@ -43,6 +43,10 @@ interface PrShepherdConfig {
|
|
|
43
43
|
mergeStatus: {
|
|
44
44
|
blockingReviewerLogins: string[];
|
|
45
45
|
};
|
|
46
|
+
merge?: {
|
|
47
|
+
/** Options added to ordinary `gh pr merge` commands. Queue commands never use these. */
|
|
48
|
+
commandArgs: string[];
|
|
49
|
+
};
|
|
46
50
|
actions: {
|
|
47
51
|
autoMinimizeSuppressed: boolean;
|
|
48
52
|
autoMarkReady: boolean;
|
package/bin/config/load.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { parse } from "yaml";
|
|
6
6
|
import builtins from "../config.json" with { type: "json" };
|
|
7
7
|
import { getEffectiveCwd } from "../execution-context.mjs";
|
|
8
|
+
import { findMergeStrategies } from "./merge-command-args.mjs";
|
|
8
9
|
const MINIMIZE_COMMENTS_POLICIES = ["all", "bots", "users", "none"];
|
|
9
10
|
const RC_FILENAME = ".pr-shepherdrc.yml";
|
|
10
11
|
/**
|
|
@@ -85,6 +86,35 @@ function parseNeverCancelRuns(value) {
|
|
|
85
86
|
}
|
|
86
87
|
return value;
|
|
87
88
|
}
|
|
89
|
+
const SHEPHERD_OWNED_MERGE_FLAGS = [
|
|
90
|
+
"--repo",
|
|
91
|
+
"-R",
|
|
92
|
+
"--auto",
|
|
93
|
+
"--disable-auto",
|
|
94
|
+
"--match-head-commit",
|
|
95
|
+
"--admin",
|
|
96
|
+
"--body-file",
|
|
97
|
+
"-F",
|
|
98
|
+
"--help",
|
|
99
|
+
"-h",
|
|
100
|
+
];
|
|
101
|
+
function parseMergeCommandArgs(value) {
|
|
102
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
103
|
+
throw new Error("Invalid config: merge.commandArgs must be an array of strings");
|
|
104
|
+
}
|
|
105
|
+
for (const arg of value) {
|
|
106
|
+
if (SHEPHERD_OWNED_MERGE_FLAGS.some((flag) => arg === flag ||
|
|
107
|
+
arg.startsWith(`${flag}=`) ||
|
|
108
|
+
(flag.startsWith("-") && !flag.startsWith("--") && arg.startsWith(flag)))) {
|
|
109
|
+
throw new Error(`Invalid config: merge.commandArgs cannot include Shepherd-owned ${arg}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const strategies = findMergeStrategies(value);
|
|
113
|
+
if (strategies.length > 1) {
|
|
114
|
+
throw new Error(`Invalid config: merge.commandArgs includes multiple merge strategies: ${strategies.join(", ")}`);
|
|
115
|
+
}
|
|
116
|
+
return strategies.length === 0 ? [...value, "--merge"] : [...value];
|
|
117
|
+
}
|
|
88
118
|
const KNOWN_CONFIG_KEYS = new Set([
|
|
89
119
|
"classify",
|
|
90
120
|
"botUsernames",
|
|
@@ -94,6 +124,7 @@ const KNOWN_CONFIG_KEYS = new Set([
|
|
|
94
124
|
"resolve",
|
|
95
125
|
"checks",
|
|
96
126
|
"mergeStatus",
|
|
127
|
+
"merge",
|
|
97
128
|
"actions",
|
|
98
129
|
]);
|
|
99
130
|
const KNOWN_NESTED_KEYS = {
|
|
@@ -108,6 +139,7 @@ const KNOWN_NESTED_KEYS = {
|
|
|
108
139
|
resolve: new Set(["shaPoll"]),
|
|
109
140
|
checks: new Set(["ciTriggerEvents"]),
|
|
110
141
|
mergeStatus: new Set(["blockingReviewerLogins"]),
|
|
142
|
+
merge: new Set(["commandArgs"]),
|
|
111
143
|
actions: new Set([
|
|
112
144
|
"autoMinimizeSuppressed",
|
|
113
145
|
"autoMarkReady",
|
|
@@ -194,6 +226,8 @@ export function loadConfig() {
|
|
|
194
226
|
config.botUsernames = parseBotUsernames(config.botUsernames);
|
|
195
227
|
config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
|
|
196
228
|
config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
|
|
229
|
+
if (config.merge)
|
|
230
|
+
config.merge.commandArgs = parseMergeCommandArgs(config.merge.commandArgs);
|
|
197
231
|
config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
|
|
198
232
|
configCache.set(cwd, config);
|
|
199
233
|
return config;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const MERGE_STRATEGY_FLAGS = new Map([
|
|
2
|
+
["--merge", "merge"],
|
|
3
|
+
["-m", "merge"],
|
|
4
|
+
["--squash", "squash"],
|
|
5
|
+
["-s", "squash"],
|
|
6
|
+
["--rebase", "rebase"],
|
|
7
|
+
["-r", "rebase"],
|
|
8
|
+
]);
|
|
9
|
+
/** Return active merge strategies while validating compound strategy syntax. */
|
|
10
|
+
export function findMergeStrategies(args) {
|
|
11
|
+
const strategies = [];
|
|
12
|
+
for (const arg of args) {
|
|
13
|
+
const exact = MERGE_STRATEGY_FLAGS.get(arg);
|
|
14
|
+
if (exact) {
|
|
15
|
+
strategies.push(exact);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const longAssignment = /^(--merge|--squash|--rebase)=(.*)$/.exec(arg);
|
|
19
|
+
const shortAssignment = /^(-[msr])=(.*)$/.exec(arg);
|
|
20
|
+
const assignment = longAssignment ?? shortAssignment;
|
|
21
|
+
if (assignment) {
|
|
22
|
+
if (assignment[2] !== "true" && assignment[2] !== "false") {
|
|
23
|
+
throw new Error(`Invalid config: merge.commandArgs strategy ${arg} must use true or false`);
|
|
24
|
+
}
|
|
25
|
+
if (assignment[2] === "true") {
|
|
26
|
+
strategies.push(MERGE_STRATEGY_FLAGS.get(assignment[1]));
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const shortBundle = /^-([dmrs]{2,})$/.exec(arg);
|
|
31
|
+
if (shortBundle) {
|
|
32
|
+
for (const flag of shortBundle[1]) {
|
|
33
|
+
const strategy = MERGE_STRATEGY_FLAGS.get(`-${flag}`);
|
|
34
|
+
if (strategy)
|
|
35
|
+
strategies.push(strategy);
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (/^-[dmrs].+/.test(arg)) {
|
|
40
|
+
throw new Error(`Invalid config: merge.commandArgs cannot safely parse compound short option ${arg}; use separate flags or long options`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return strategies;
|
|
44
|
+
}
|
package/bin/config.json
CHANGED
package/bin/exit-codes.d.mts
CHANGED
|
@@ -23,6 +23,8 @@ export declare const EXIT: Readonly<{
|
|
|
23
23
|
readonly ESCALATE: 13;
|
|
24
24
|
/** `cancel` + `closed` — PR closed without merging. */
|
|
25
25
|
readonly CLOSED: 14;
|
|
26
|
+
/** Agent should run the emitted merge or queue command. */
|
|
27
|
+
readonly MERGE: 15;
|
|
26
28
|
/** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
|
|
27
29
|
readonly USAGE: 64;
|
|
28
30
|
/** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
|
package/bin/exit-codes.mjs
CHANGED
|
@@ -22,6 +22,8 @@ export const EXIT = Object.freeze({
|
|
|
22
22
|
ESCALATE: 13,
|
|
23
23
|
/** `cancel` + `closed` — PR closed without merging. */
|
|
24
24
|
CLOSED: 14,
|
|
25
|
+
/** Agent should run the emitted merge or queue command. */
|
|
26
|
+
MERGE: 15,
|
|
25
27
|
/** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
|
|
26
28
|
USAGE: 64,
|
|
27
29
|
/** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
|
|
@@ -65,6 +67,8 @@ export function iterateResultToExitCode(result) {
|
|
|
65
67
|
return EXIT.FIX_CODE;
|
|
66
68
|
case "escalate":
|
|
67
69
|
return EXIT.ESCALATE;
|
|
70
|
+
case "merge":
|
|
71
|
+
return EXIT.MERGE;
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
export function errorToExitCode(err) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { mapCheckRunNode, mapStatusContextState, parseCreatedAt } from "./batch-parser-helpers.mjs";
|
|
2
|
+
export function parseCheckNodes(nodes, mergeCommitOid) {
|
|
3
|
+
return (nodes ?? []).flatMap((node) => {
|
|
4
|
+
const scope = mergeCommitOid
|
|
5
|
+
? { scope: "merge_group", commitOid: mergeCommitOid }
|
|
6
|
+
: {};
|
|
7
|
+
if (node?.__typename === "CheckRun")
|
|
8
|
+
return [{ ...mapCheckRunNode(node), ...scope }];
|
|
9
|
+
if (node?.__typename !== "StatusContext")
|
|
10
|
+
return [];
|
|
11
|
+
const { status, conclusion } = mapStatusContextState(node.state);
|
|
12
|
+
const summary = node.description?.trim() || undefined;
|
|
13
|
+
const createdAtUnix = node.createdAt ? parseCreatedAt(node.createdAt) : undefined;
|
|
14
|
+
return [
|
|
15
|
+
{
|
|
16
|
+
name: node.context,
|
|
17
|
+
status,
|
|
18
|
+
conclusion,
|
|
19
|
+
source: "status_context",
|
|
20
|
+
detailsUrl: node.targetUrl ?? "",
|
|
21
|
+
event: null,
|
|
22
|
+
runId: null,
|
|
23
|
+
...scope,
|
|
24
|
+
...(createdAtUnix !== undefined && { createdAtUnix }),
|
|
25
|
+
...(summary !== undefined && { summary }),
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type { BranchRules, MergeQueueEntryStatus, StackStatus } from "../types.mts";
|
|
1
|
+
import type { AutoMergeRequestStatus, BranchRules, MergeQueueEntryStatus, MergeQueueRemovalStatus, StackStatus } from "../types.mts";
|
|
2
2
|
import type { RawBaseRef, RawPrMergeFields } from "./batch-raw-rules.mts";
|
|
3
3
|
export declare const EMPTY_BRANCH_RULES: BranchRules;
|
|
4
4
|
export declare function parseBranchRules(baseRef: RawBaseRef | null | undefined): BranchRules;
|
|
5
5
|
export declare function parseMergeQueueEntry(raw: RawPrMergeFields["mergeQueueEntry"]): MergeQueueEntryStatus | null;
|
|
6
|
+
export declare function parseAutoMergeRequest(raw: RawPrMergeFields["autoMergeRequest"]): AutoMergeRequestStatus | null;
|
|
7
|
+
export declare function parseLatestMergeQueueRemoval(raw: RawPrMergeFields): MergeQueueRemovalStatus | null;
|
|
6
8
|
export declare function parseStack(raw: RawPrMergeFields): StackStatus | null;
|
|
@@ -32,8 +32,41 @@ export function parseMergeQueueEntry(raw) {
|
|
|
32
32
|
position: raw.position,
|
|
33
33
|
state: raw.state,
|
|
34
34
|
estimatedTimeToMerge: raw.estimatedTimeToMerge,
|
|
35
|
+
...(raw.headCommit?.oid && { headCommitOid: raw.headCommit.oid }),
|
|
36
|
+
...(raw.enqueuedAt && { enqueuedAtUnix: parseUnix(raw.enqueuedAt) }),
|
|
37
|
+
...(raw.enqueuer?.login && { enqueuer: raw.enqueuer.login }),
|
|
35
38
|
};
|
|
36
39
|
}
|
|
40
|
+
export function parseAutoMergeRequest(raw) {
|
|
41
|
+
if (!raw)
|
|
42
|
+
return null;
|
|
43
|
+
return {
|
|
44
|
+
enabledAtUnix: parseUnix(raw.enabledAt),
|
|
45
|
+
mergeMethod: raw.mergeMethod,
|
|
46
|
+
...(raw.enabledBy?.login && { enabledBy: raw.enabledBy.login }),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function parseLatestMergeQueueRemoval(raw) {
|
|
50
|
+
const removal = raw.mergeQueueRemovals?.nodes[0];
|
|
51
|
+
if (!removal)
|
|
52
|
+
return null;
|
|
53
|
+
const lastAddedAt = raw.mergeQueueAdditions?.nodes[0]?.createdAt;
|
|
54
|
+
if (lastAddedAt && Date.parse(lastAddedAt) > Date.parse(removal.createdAt))
|
|
55
|
+
return null;
|
|
56
|
+
return {
|
|
57
|
+
reason: removal.reason,
|
|
58
|
+
createdAtUnix: parseUnix(removal.createdAt),
|
|
59
|
+
...(removal.actor?.login && { actor: removal.actor.login }),
|
|
60
|
+
...(removal.beforeCommit?.oid && { beforeCommitOid: removal.beforeCommit.oid }),
|
|
61
|
+
...(removal.beforeCommit?.parents && {
|
|
62
|
+
beforeCommitParentOids: removal.beforeCommit.parents.nodes.map((parent) => parent.oid),
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function parseUnix(value) {
|
|
67
|
+
const ms = Date.parse(value);
|
|
68
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
69
|
+
}
|
|
37
70
|
export function parseStack(raw) {
|
|
38
71
|
if (!raw.stack)
|
|
39
72
|
return null;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { mapAuthorType, parseCreatedAt,
|
|
1
|
+
import { mapAuthorType, parseCreatedAt, latestApprovedLogins, isReviewStale, } from "./batch-parser-helpers.mjs";
|
|
2
|
+
import { parseCheckNodes } from "./batch-parse-checks.mjs";
|
|
3
|
+
import { requireContextNodes } from "./batch-response.mjs";
|
|
2
4
|
import { buildPrActivitySummary } from "./activity.mjs";
|
|
3
5
|
import { parseBranchProtection } from "./branch-protection.mjs";
|
|
4
|
-
import { parseBranchRules, parseMergeQueueEntry, parseStack } from "./batch-parsers-rules.mjs";
|
|
6
|
+
import { parseAutoMergeRequest, parseBranchRules, parseLatestMergeQueueRemoval, parseMergeQueueEntry, parseStack, } from "./batch-parsers-rules.mjs";
|
|
5
7
|
function parseReviewNode(r) {
|
|
6
8
|
const base = {
|
|
7
9
|
id: r.id,
|
|
@@ -86,30 +88,15 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
86
88
|
const approvedReviews = rawApprovedReviewNodes
|
|
87
89
|
.filter((r) => !r.isMinimized)
|
|
88
90
|
.map((r) => parseReviewNode(r));
|
|
89
|
-
const checks = rawCheckNodes
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
{
|
|
99
|
-
name: node.context,
|
|
100
|
-
status,
|
|
101
|
-
conclusion,
|
|
102
|
-
source: "status_context",
|
|
103
|
-
detailsUrl: node.targetUrl ?? "",
|
|
104
|
-
event: null,
|
|
105
|
-
runId: null,
|
|
106
|
-
...(createdAtUnix !== undefined && { createdAtUnix }),
|
|
107
|
-
...(summary !== undefined && { summary }),
|
|
108
|
-
},
|
|
109
|
-
];
|
|
110
|
-
}
|
|
111
|
-
return [];
|
|
112
|
-
});
|
|
91
|
+
const checks = parseCheckNodes(rawCheckNodes);
|
|
92
|
+
const queueHead = raw.mergeQueueEntry?.headCommit;
|
|
93
|
+
const removedQueueHead = raw.mergeQueueRemovals?.nodes[0]?.beforeCommit;
|
|
94
|
+
const mergeQueueChecks = parseCheckNodes(queueHead?.statusCheckRollup
|
|
95
|
+
? requireContextNodes(queueHead.statusCheckRollup.contexts.nodes)
|
|
96
|
+
: undefined, queueHead?.oid);
|
|
97
|
+
const removedMergeQueueChecks = parseCheckNodes(removedQueueHead?.statusCheckRollup
|
|
98
|
+
? requireContextNodes(removedQueueHead.statusCheckRollup.contexts.nodes)
|
|
99
|
+
: undefined, removedQueueHead?.oid);
|
|
113
100
|
return {
|
|
114
101
|
nodeId: raw.id,
|
|
115
102
|
number: raw.number,
|
|
@@ -135,6 +122,16 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
135
122
|
isInMergeQueue: raw.isInMergeQueue ?? false,
|
|
136
123
|
isMergeQueueEnabled: raw.isMergeQueueEnabled ?? false,
|
|
137
124
|
mergeQueueEntry: parseMergeQueueEntry(raw.mergeQueueEntry),
|
|
125
|
+
autoMergeRequest: parseAutoMergeRequest(raw.autoMergeRequest),
|
|
126
|
+
latestMergeQueueRemoval: parseLatestMergeQueueRemoval(raw),
|
|
127
|
+
...(mergeQueueChecks.length > 0 && { mergeQueueChecks }),
|
|
128
|
+
...(queueHead?.statusCheckRollup?.contexts.pageInfo.hasNextPage && {
|
|
129
|
+
mergeQueueChecksIncomplete: true,
|
|
130
|
+
}),
|
|
131
|
+
...(removedMergeQueueChecks.length > 0 && { removedMergeQueueChecks }),
|
|
132
|
+
...(removedQueueHead?.statusCheckRollup?.contexts.pageInfo.hasNextPage && {
|
|
133
|
+
removedMergeQueueChecksIncomplete: true,
|
|
134
|
+
}),
|
|
138
135
|
stack: parseStack(raw),
|
|
139
136
|
activity: buildPrActivitySummary(raw, comments, reviewThreads, reviewSummaries, allChangesRequestedReviews, approvedReviews),
|
|
140
137
|
};
|
|
@@ -12,10 +12,49 @@ export interface RawBranchProtectionRule {
|
|
|
12
12
|
requiresDeployments?: boolean;
|
|
13
13
|
requiredDeploymentEnvironments?: string[] | null;
|
|
14
14
|
}
|
|
15
|
+
import type { RawContextNode } from "./batch-raw-types.mts";
|
|
16
|
+
interface RawCheckCommit {
|
|
17
|
+
oid: string;
|
|
18
|
+
committedDate?: string;
|
|
19
|
+
parents?: {
|
|
20
|
+
nodes: Array<{
|
|
21
|
+
oid: string;
|
|
22
|
+
}>;
|
|
23
|
+
};
|
|
24
|
+
statusCheckRollup: {
|
|
25
|
+
contexts: {
|
|
26
|
+
pageInfo: {
|
|
27
|
+
hasNextPage: boolean;
|
|
28
|
+
endCursor: string | null;
|
|
29
|
+
};
|
|
30
|
+
nodes: Array<RawContextNode | null>;
|
|
31
|
+
};
|
|
32
|
+
} | null;
|
|
33
|
+
}
|
|
15
34
|
interface RawMergeQueueEntry {
|
|
16
35
|
position: number;
|
|
17
36
|
state: string;
|
|
18
37
|
estimatedTimeToMerge: number | null;
|
|
38
|
+
headCommit?: RawCheckCommit | null;
|
|
39
|
+
enqueuedAt?: string;
|
|
40
|
+
enqueuer?: {
|
|
41
|
+
login: string;
|
|
42
|
+
} | null;
|
|
43
|
+
}
|
|
44
|
+
interface RawAutoMergeRequest {
|
|
45
|
+
enabledAt: string;
|
|
46
|
+
mergeMethod: string;
|
|
47
|
+
enabledBy?: {
|
|
48
|
+
login: string;
|
|
49
|
+
} | null;
|
|
50
|
+
}
|
|
51
|
+
interface RawMergeQueueRemoval {
|
|
52
|
+
reason: string | null;
|
|
53
|
+
actor?: {
|
|
54
|
+
login: string;
|
|
55
|
+
} | null;
|
|
56
|
+
createdAt: string;
|
|
57
|
+
beforeCommit?: RawCheckCommit | null;
|
|
19
58
|
}
|
|
20
59
|
interface RawStack {
|
|
21
60
|
number: number;
|
|
@@ -50,6 +89,15 @@ export interface RawPrMergeFields {
|
|
|
50
89
|
isInMergeQueue?: boolean;
|
|
51
90
|
isMergeQueueEnabled?: boolean;
|
|
52
91
|
mergeQueueEntry?: RawMergeQueueEntry | null;
|
|
92
|
+
autoMergeRequest?: RawAutoMergeRequest | null;
|
|
93
|
+
mergeQueueRemovals?: {
|
|
94
|
+
nodes: RawMergeQueueRemoval[];
|
|
95
|
+
} | null;
|
|
96
|
+
mergeQueueAdditions?: {
|
|
97
|
+
nodes: Array<{
|
|
98
|
+
createdAt: string;
|
|
99
|
+
}>;
|
|
100
|
+
} | null;
|
|
53
101
|
stack?: RawStack | null;
|
|
54
102
|
stackEntry?: {
|
|
55
103
|
position: number;
|
package/bin/github/batch.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { parseCheckSuitesComplete, parseSuiteStartupFailures } from "./batch-par
|
|
|
6
6
|
import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
|
|
7
7
|
import { paginateBatchConnections } from "./batch-page.mjs";
|
|
8
8
|
import { requireRawPr } from "./batch-response.mjs";
|
|
9
|
+
import { hydrateMergeQueueChecks } from "./merge-queue-checks.mjs";
|
|
9
10
|
/**
|
|
10
11
|
* Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
|
|
11
12
|
*/
|
|
@@ -16,6 +17,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
16
17
|
pr,
|
|
17
18
|
});
|
|
18
19
|
const raw = requireRawPr(result.data, pr, repo);
|
|
20
|
+
await hydrateMergeQueueChecks(raw, repo);
|
|
19
21
|
const paged = await paginateBatchConnections(pr, repo, raw, opts, result.rateLimit);
|
|
20
22
|
const rawThreadPages = await hydrateThreadCommentPages(paged.threads);
|
|
21
23
|
const data = parseRawPr(raw, rawThreadPages, paged.comments, paged.changesRequested, paged.reviewSummaries, paged.approvedReviews, paged.checks);
|