@a2f0/agent-tool 0.1.1
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/README.md +288 -0
- package/package.json +36 -0
- package/skills/cross-agent-review/SKILL.md +46 -0
- package/skills/open-pr/SKILL.md +31 -0
- package/skills/reset/SKILL.md +25 -0
- package/skills/ship-pr/SKILL.md +48 -0
- package/skills/squash-merge/SKILL.md +39 -0
- package/src/cli.ts +121 -0
- package/src/config.ts +101 -0
- package/src/doctor.ts +23 -0
- package/src/git/currentBranchPr.ts +114 -0
- package/src/git/environment.ts +10 -0
- package/src/git/prContext.ts +497 -0
- package/src/harnesses.ts +53 -0
- package/src/index.ts +9 -0
- package/src/pr/assertBranchPushed.ts +21 -0
- package/src/pr/assertNoClaudeBranding.ts +52 -0
- package/src/pr/baseFreshness.ts +67 -0
- package/src/pr/mergeChecks.ts +90 -0
- package/src/pr/openPr.ts +80 -0
- package/src/pr/prNumberSuffix.ts +43 -0
- package/src/pr/squashMerge.ts +229 -0
- package/src/pr/subjectLine.ts +21 -0
- package/src/pr/validateCommitSubject.ts +12 -0
- package/src/review/destinationPaths.ts +154 -0
- package/src/review/executable.ts +34 -0
- package/src/review/pinnedReviewInput.ts +91 -0
- package/src/review/rawGitTree.ts +301 -0
- package/src/review/reviewEffort.ts +54 -0
- package/src/review/reviewOutput.ts +62 -0
- package/src/review/reviewPrompt.ts +104 -0
- package/src/review/reviewSnapshot.ts +67 -0
- package/src/review/runReview.ts +62 -0
- package/src/review/solicitClaudeCodeReview.ts +140 -0
- package/src/review/solicitCodexReview.ts +205 -0
- package/src/review/solicitOpencodeReview.ts +233 -0
- package/src/runAgentToolAction.ts +100 -0
- package/src/skills/bundled.ts +8 -0
- package/src/skills/install.ts +92 -0
- package/src/version/bumpVersions.ts +196 -0
- package/src/version/lockfileVersions.ts +43 -0
- package/src/version/packageVersion.ts +112 -0
- package/src/version/prepareVersions.ts +135 -0
- package/src/version/resolveVersionConflicts.ts +126 -0
- package/src/version/versionPreparationGit.ts +127 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { run } from "../git/prContext";
|
|
2
|
+
import { loadConfig, type AgentToolConfig } from "../config";
|
|
3
|
+
|
|
4
|
+
type CheckPolicy = Pick<AgentToolConfig["merge"], "requireChecks" | "requiredChecks">;
|
|
5
|
+
|
|
6
|
+
function field(value: unknown, key: string): unknown {
|
|
7
|
+
return typeof value === "object" && value !== null
|
|
8
|
+
? Reflect.get(value, key)
|
|
9
|
+
: undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function assertMergeChecks(source: string, expectedHead: string, policy: CheckPolicy = loadConfig().merge): void {
|
|
13
|
+
const payload: unknown = JSON.parse(source);
|
|
14
|
+
if (field(payload, "headRefOid") !== expectedHead) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
"PR head changed while checking CI; re-review before merging.",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
const checks = field(payload, "checks");
|
|
20
|
+
if (!Array.isArray(checks)) throw new Error("Could not read CI checks; refusing to merge.");
|
|
21
|
+
if (checks.length === 0 && policy.requireChecks) {
|
|
22
|
+
throw new Error("PR has no CI checks; refusing to merge.");
|
|
23
|
+
}
|
|
24
|
+
for (const check of checks) {
|
|
25
|
+
const name = field(check, "name");
|
|
26
|
+
const success = field(check, "state") === "SUCCESS";
|
|
27
|
+
const skipped = field(check, "state") === "SKIPPED";
|
|
28
|
+
if (!success && !skipped) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`CI check '${String(name)}' has not passed; refusing to merge.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const required of policy.requiredChecks) {
|
|
35
|
+
if (!checks.some(check => field(check, "name") === required.name && field(check, "state") === "SUCCESS" && (required.workflow === undefined || field(check, "workflow") === required.workflow))) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Required CI check '${required.name}' has not passed; refusing to merge.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function requirePassingMergeChecks(
|
|
44
|
+
pr: { readonly prNumber: string; readonly repo: string },
|
|
45
|
+
expectedHead: string,
|
|
46
|
+
read: typeof run = run,
|
|
47
|
+
policy: CheckPolicy = loadConfig().merge,
|
|
48
|
+
): void {
|
|
49
|
+
// gh pr checks paginates and selects the latest run per workflow/job/event.
|
|
50
|
+
// Raw statusCheckRollup can retain failures from superseded runs. JSON mode
|
|
51
|
+
// can exit zero for pending/failed checks: assertMergeChecks enforces states.
|
|
52
|
+
// API and other command failures also throw before any merge.
|
|
53
|
+
let checks: unknown;
|
|
54
|
+
try {
|
|
55
|
+
checks = JSON.parse(
|
|
56
|
+
read("gh", [
|
|
57
|
+
"pr",
|
|
58
|
+
"checks",
|
|
59
|
+
pr.prNumber,
|
|
60
|
+
"-R",
|
|
61
|
+
pr.repo,
|
|
62
|
+
"--json",
|
|
63
|
+
"name,state,workflow",
|
|
64
|
+
]),
|
|
65
|
+
);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (policy.requireChecks || policy.requiredChecks.length) throw error;
|
|
68
|
+
// gh pr checks exits nonzero when no checks exist. Independently confirm
|
|
69
|
+
// an empty rollup before honoring an explicit no-check project policy.
|
|
70
|
+
const rollup = field(JSON.parse(read("gh", ["pr", "view", pr.prNumber, "-R", pr.repo, "--json", "statusCheckRollup"])), "statusCheckRollup");
|
|
71
|
+
if (!Array.isArray(rollup) || rollup.length !== 0) throw error;
|
|
72
|
+
checks = [];
|
|
73
|
+
}
|
|
74
|
+
const head: unknown = JSON.parse(
|
|
75
|
+
read("gh", [
|
|
76
|
+
"pr",
|
|
77
|
+
"view",
|
|
78
|
+
pr.prNumber,
|
|
79
|
+
"-R",
|
|
80
|
+
pr.repo,
|
|
81
|
+
"--json",
|
|
82
|
+
"headRefOid",
|
|
83
|
+
]),
|
|
84
|
+
);
|
|
85
|
+
assertMergeChecks(
|
|
86
|
+
JSON.stringify({ headRefOid: field(head, "headRefOid"), checks }),
|
|
87
|
+
expectedHead,
|
|
88
|
+
policy,
|
|
89
|
+
);
|
|
90
|
+
}
|
package/src/pr/openPr.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
findOpenPrNumber,
|
|
5
|
+
remoteBranchHead,
|
|
6
|
+
resolveRepoContext,
|
|
7
|
+
resolveRepositoryGitUrl,
|
|
8
|
+
run,
|
|
9
|
+
spawnExitCode,
|
|
10
|
+
} from "../git/prContext";
|
|
11
|
+
import { assertBranchPushed } from "./assertBranchPushed";
|
|
12
|
+
import { assertNoClaudeBranding } from "./assertNoClaudeBranding";
|
|
13
|
+
import { singleLineSubject } from "./subjectLine";
|
|
14
|
+
import { validateCommitSubject } from "./validateCommitSubject";
|
|
15
|
+
import { loadConfig } from "../config";
|
|
16
|
+
|
|
17
|
+
/** Read the PR body from stdin, or "" when stdin is a terminal/empty. */
|
|
18
|
+
function readBody(): string {
|
|
19
|
+
// With no pipe/redirect, fd 0 is an open TTY and a blocking read would hang.
|
|
20
|
+
if (process.stdin.isTTY) {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
return readFileSync(0, "utf8");
|
|
25
|
+
} catch {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Open a pull request for the current branch with a policy-conforming
|
|
32
|
+
* title. The title defaults to the branch's latest commit subject, is validated
|
|
33
|
+
* against data-only project policy before the PR is created; the body is read from
|
|
34
|
+
* stdin. The base defaults to the repository's default branch. The branch must
|
|
35
|
+
* already be pushed at the local head; this never pushes.
|
|
36
|
+
*/
|
|
37
|
+
export function openPr(rootDir: string, titleArg: string | undefined): number {
|
|
38
|
+
const { branch, repo, defaultBranch } = resolveRepoContext();
|
|
39
|
+
|
|
40
|
+
const existing = findOpenPrNumber(branch, repo);
|
|
41
|
+
if (existing.length > 0) {
|
|
42
|
+
throw new Error(`An open PR already exists for '${branch}': #${existing}.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tipSubject = run("git", ["log", "-1", "--format=%s"]);
|
|
46
|
+
const title = singleLineSubject(titleArg, tipSubject, "PR title");
|
|
47
|
+
validateCommitSubject(rootDir, title);
|
|
48
|
+
|
|
49
|
+
const body = readBody();
|
|
50
|
+
if (loadConfig(rootDir).pr.rejectClaudeBranding) assertNoClaudeBranding(body);
|
|
51
|
+
|
|
52
|
+
// Local validation first; the remote head is the last thing checked.
|
|
53
|
+
assertBranchPushed({
|
|
54
|
+
branch,
|
|
55
|
+
localHead: run("git", ["rev-parse", "HEAD"]),
|
|
56
|
+
remoteHead: remoteBranchHead(resolveRepositoryGitUrl(repo), branch),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Pin the base to the repo default branch; without --base, gh honors a
|
|
60
|
+
// branch.<name>.gh-merge-base git config that could target another branch.
|
|
61
|
+
const baseArgs = defaultBranch.length > 0 ? ["--base", defaultBranch] : [];
|
|
62
|
+
const result = spawnSync(
|
|
63
|
+
"gh",
|
|
64
|
+
[
|
|
65
|
+
"pr",
|
|
66
|
+
"create",
|
|
67
|
+
"--title",
|
|
68
|
+
title,
|
|
69
|
+
"--body-file",
|
|
70
|
+
"-",
|
|
71
|
+
"--head",
|
|
72
|
+
branch,
|
|
73
|
+
...baseArgs,
|
|
74
|
+
"-R",
|
|
75
|
+
repo,
|
|
76
|
+
],
|
|
77
|
+
{ input: body, stdio: ["pipe", "inherit", "inherit"] },
|
|
78
|
+
);
|
|
79
|
+
return spawnExitCode("gh pr create", result);
|
|
80
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Matches a trailing GitHub reference like " (#1531)" at the very end of a
|
|
2
|
+
// subject line. Anchored to the end so a mid-subject reference is left intact,
|
|
3
|
+
// and un-globbed so only the final reference is removed per call.
|
|
4
|
+
const PR_NUMBER_SUFFIX = /\s*\(#\d+\)$/;
|
|
5
|
+
|
|
6
|
+
/** Strip the single trailing `(#<n>)` reference from a subject, if present. */
|
|
7
|
+
export function stripPrNumberSuffix(subject: string): string {
|
|
8
|
+
return subject.replace(PR_NUMBER_SUFFIX, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Append ` (#<prNumber>)` to a subject that has already had its trailing
|
|
13
|
+
* reference removed (see `stripPrNumberSuffix`). Deliberately strip-free: the
|
|
14
|
+
* caller strips exactly once, so the validated base and the merged subject stay
|
|
15
|
+
* in sync and an earlier `(#<n>)` reference is never silently discarded. Throws
|
|
16
|
+
* when `prNumber` is not a positive integer.
|
|
17
|
+
*/
|
|
18
|
+
export function appendPrNumberSuffix(
|
|
19
|
+
baseSubject: string,
|
|
20
|
+
prNumber: string,
|
|
21
|
+
): string {
|
|
22
|
+
if (!/^\d+$/.test(prNumber)) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Invalid PR number '${prNumber}' for the squash subject suffix.`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const result = `${baseSubject} (#${prNumber})`;
|
|
28
|
+
assertPrNumberSuffix(result, prNumber);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Assert a subject ends with the exact `(#<prNumber>)` reference — the invariant
|
|
34
|
+
* every squash subject must satisfy before the merge runs.
|
|
35
|
+
*/
|
|
36
|
+
export function assertPrNumberSuffix(subject: string, prNumber: string): void {
|
|
37
|
+
const suffix = ` (#${prNumber})`;
|
|
38
|
+
if (!subject.endsWith(suffix)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Squash subject must end with '${suffix}', got: '${subject}'.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { loadConfig } from "../config";
|
|
4
|
+
import { prState, resolvePr, run, spawnExitCode } from "../git/prContext";
|
|
5
|
+
import { assertStrictBaseFreshness } from "./baseFreshness";
|
|
6
|
+
import { requirePassingMergeChecks } from "./mergeChecks";
|
|
7
|
+
import {
|
|
8
|
+
appendPrNumberSuffix,
|
|
9
|
+
assertPrNumberSuffix,
|
|
10
|
+
stripPrNumberSuffix,
|
|
11
|
+
} from "./prNumberSuffix";
|
|
12
|
+
import { singleLineSubject } from "./subjectLine";
|
|
13
|
+
import { validateCommitSubject } from "./validateCommitSubject";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the squash subject from the CLI argument, falling back to the PR
|
|
17
|
+
* title. Rejects an empty subject and any embedded line break so the squash
|
|
18
|
+
* commit stays a single subject line.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveSubject(
|
|
21
|
+
subjectArg: string | undefined,
|
|
22
|
+
prTitle: string,
|
|
23
|
+
): string {
|
|
24
|
+
return singleLineSubject(subjectArg, prTitle, "squash subject");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PULL_REQUEST_MERGE_TARGET_QUERY = `
|
|
28
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
29
|
+
repository(owner: $owner, name: $name) {
|
|
30
|
+
pullRequest(number: $number) {
|
|
31
|
+
autoMergeRequest { enabledAt }
|
|
32
|
+
baseRefName
|
|
33
|
+
headRefOid
|
|
34
|
+
id
|
|
35
|
+
isInMergeQueue
|
|
36
|
+
state
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const MERGE_PULL_REQUEST_MUTATION = `
|
|
43
|
+
mutation(
|
|
44
|
+
$pullRequestId: ID!
|
|
45
|
+
$commitHeadline: String!
|
|
46
|
+
$commitBody: String!
|
|
47
|
+
$expectedHeadOid: GitObjectID!
|
|
48
|
+
$mergeMethod: PullRequestMergeMethod!
|
|
49
|
+
) {
|
|
50
|
+
mergePullRequest(input: {
|
|
51
|
+
pullRequestId: $pullRequestId
|
|
52
|
+
commitHeadline: $commitHeadline
|
|
53
|
+
commitBody: $commitBody
|
|
54
|
+
expectedHeadOid: $expectedHeadOid
|
|
55
|
+
mergeMethod: $mergeMethod
|
|
56
|
+
}) {
|
|
57
|
+
pullRequest { state }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
interface PullRequestMergeTarget {
|
|
63
|
+
readonly baseRefName: string;
|
|
64
|
+
readonly headOid: string;
|
|
65
|
+
readonly id: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function recordField(value: unknown, key: string): unknown {
|
|
69
|
+
return typeof value === "object" && value !== null
|
|
70
|
+
? Reflect.get(value, key)
|
|
71
|
+
: undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parse the current target and reject unsafe asynchronous merge state. */
|
|
75
|
+
export function parsePullRequestMergeTarget(
|
|
76
|
+
source: string,
|
|
77
|
+
expectedBaseRef?: string,
|
|
78
|
+
): PullRequestMergeTarget {
|
|
79
|
+
const parsed: unknown = JSON.parse(source);
|
|
80
|
+
const data = recordField(parsed, "data");
|
|
81
|
+
const repository = recordField(data, "repository");
|
|
82
|
+
const pullRequest = recordField(repository, "pullRequest");
|
|
83
|
+
const baseRefName = recordField(pullRequest, "baseRefName");
|
|
84
|
+
const id = recordField(pullRequest, "id");
|
|
85
|
+
const headOid = recordField(pullRequest, "headRefOid");
|
|
86
|
+
const state = recordField(pullRequest, "state");
|
|
87
|
+
if (
|
|
88
|
+
state !== "OPEN" ||
|
|
89
|
+
typeof baseRefName !== "string" ||
|
|
90
|
+
typeof id !== "string" ||
|
|
91
|
+
typeof headOid !== "string"
|
|
92
|
+
) {
|
|
93
|
+
throw new Error("Could not resolve an open pull request merge target.");
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
recordField(pullRequest, "isInMergeQueue") === true ||
|
|
97
|
+
recordField(pullRequest, "autoMergeRequest") !== null
|
|
98
|
+
) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"Pull request already has a queued or automatic merge; cancel it before running squashMerge.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (expectedBaseRef !== undefined && baseRefName !== expectedBaseRef) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Pull request base changed from '${expectedBaseRef}' to '${baseRefName}'; revalidate and re-review before merging.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return { baseRefName, headOid, id };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolvePullRequestMergeTarget(
|
|
112
|
+
pr: {
|
|
113
|
+
prNumber: string;
|
|
114
|
+
repo: string;
|
|
115
|
+
},
|
|
116
|
+
expectedBaseRef?: string,
|
|
117
|
+
): PullRequestMergeTarget {
|
|
118
|
+
const repoParts = pr.repo.split("/");
|
|
119
|
+
if (repoParts.length !== 2 || repoParts.some((part) => part.length === 0)) {
|
|
120
|
+
throw new Error(`Invalid repository slug '${pr.repo}'.`);
|
|
121
|
+
}
|
|
122
|
+
const [owner, name] = repoParts;
|
|
123
|
+
const source = run("gh", [
|
|
124
|
+
"api",
|
|
125
|
+
"graphql",
|
|
126
|
+
"-f",
|
|
127
|
+
`query=${PULL_REQUEST_MERGE_TARGET_QUERY}`,
|
|
128
|
+
"-f",
|
|
129
|
+
`owner=${owner}`,
|
|
130
|
+
"-f",
|
|
131
|
+
`name=${name}`,
|
|
132
|
+
"-F",
|
|
133
|
+
`number=${pr.prNumber}`,
|
|
134
|
+
]);
|
|
135
|
+
return parsePullRequestMergeTarget(source, expectedBaseRef);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build a direct GraphQL merge mutation for a subject-only squash. Unlike
|
|
140
|
+
* `gh pr merge`, this cannot silently enable auto-merge or enqueue the PR.
|
|
141
|
+
*/
|
|
142
|
+
export function buildSquashMergeArgs(
|
|
143
|
+
target: PullRequestMergeTarget,
|
|
144
|
+
finalSubject: string,
|
|
145
|
+
prNumber: string,
|
|
146
|
+
expectedHeadSha?: string,
|
|
147
|
+
): string[] {
|
|
148
|
+
// Keep the final assertion at the mutation boundary so no caller can build a
|
|
149
|
+
// custom headline that accidentally suppresses GitHub's PR reference.
|
|
150
|
+
assertPrNumberSuffix(finalSubject, prNumber);
|
|
151
|
+
return [
|
|
152
|
+
"api",
|
|
153
|
+
"graphql",
|
|
154
|
+
"-f",
|
|
155
|
+
`query=${MERGE_PULL_REQUEST_MUTATION}`,
|
|
156
|
+
"-f",
|
|
157
|
+
`pullRequestId=${target.id}`,
|
|
158
|
+
"-f",
|
|
159
|
+
`commitHeadline=${finalSubject}`,
|
|
160
|
+
// Empty body keeps the squash commit to the subject line only.
|
|
161
|
+
"-f",
|
|
162
|
+
"commitBody=",
|
|
163
|
+
"-f",
|
|
164
|
+
`expectedHeadOid=${expectedHeadSha || target.headOid}`,
|
|
165
|
+
"-f",
|
|
166
|
+
"mergeMethod=SQUASH",
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Squash-merge the open PR for the current branch with a subject-only commit
|
|
172
|
+
* message — no auto-generated body or extended message. The subject defaults to
|
|
173
|
+
* the PR title when one is not supplied, and is validated against the repo's
|
|
174
|
+
* data-only subject policy before the merge runs. When `expectedHeadSha` is supplied the
|
|
175
|
+
* merge is bound to that commit through GraphQL's `expectedHeadOid` input. An
|
|
176
|
+
* expected base ref is checked immediately before the mutation, but GitHub has
|
|
177
|
+
* no corresponding atomic merge precondition for the target branch; policy can
|
|
178
|
+
* require a non-bypassable strict status rule that makes GitHub enforce it.
|
|
179
|
+
*/
|
|
180
|
+
export function squashMerge(
|
|
181
|
+
rootDir: string,
|
|
182
|
+
subjectArg: string | undefined,
|
|
183
|
+
expectedHeadSha?: string,
|
|
184
|
+
expectedBaseRef?: string,
|
|
185
|
+
): number {
|
|
186
|
+
const pr = resolvePr();
|
|
187
|
+
const subject = resolveSubject(subjectArg, pr.title);
|
|
188
|
+
|
|
189
|
+
// Validate the human-authored subject without the PR-number suffix: GitHub's
|
|
190
|
+
// native squash appends `(#<n>)` server-side, past the commit-msg hook, so the
|
|
191
|
+
// repo's existing history carries suffixes over its configured header limit. Keep
|
|
192
|
+
// the suffix "free" here too by validating the base, then append it ourselves —
|
|
193
|
+
// a custom GraphQL commit headline otherwise drops GitHub's automatic reference.
|
|
194
|
+
const baseSubject = stripPrNumberSuffix(subject);
|
|
195
|
+
validateCommitSubject(rootDir, baseSubject);
|
|
196
|
+
const finalSubject = appendPrNumberSuffix(baseSubject, pr.prNumber);
|
|
197
|
+
const mergeTarget = resolvePullRequestMergeTarget(pr, expectedBaseRef);
|
|
198
|
+
if (loadConfig(rootDir).merge.requireStrictBaseFreshness) {
|
|
199
|
+
assertStrictBaseFreshness(pr.repo, mergeTarget.baseRefName);
|
|
200
|
+
}
|
|
201
|
+
requirePassingMergeChecks(pr, expectedHeadSha || mergeTarget.headOid);
|
|
202
|
+
|
|
203
|
+
const result = spawnSync(
|
|
204
|
+
"gh",
|
|
205
|
+
buildSquashMergeArgs(
|
|
206
|
+
mergeTarget,
|
|
207
|
+
finalSubject,
|
|
208
|
+
pr.prNumber,
|
|
209
|
+
expectedHeadSha,
|
|
210
|
+
),
|
|
211
|
+
{ stdio: "inherit" },
|
|
212
|
+
);
|
|
213
|
+
const exitCode = spawnExitCode("GitHub mergePullRequest mutation", result);
|
|
214
|
+
if (exitCode !== 0) {
|
|
215
|
+
return exitCode;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Confirm that GitHub committed the direct mutation before cleanup can run.
|
|
219
|
+
const state = prState(pr.prNumber, pr.repo);
|
|
220
|
+
if (state !== "MERGED") {
|
|
221
|
+
process.stderr.write(
|
|
222
|
+
`PR #${pr.prNumber} is not merged (state: ${state || "unknown"}). ` +
|
|
223
|
+
"The synchronous subject-only squash did not land.\n",
|
|
224
|
+
);
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a single-line commit-style subject: trim the supplied value, fall
|
|
3
|
+
* back to `fallback` when it is empty, and reject an empty result or any
|
|
4
|
+
* embedded line break so the value stays a single header line (commitlint would
|
|
5
|
+
* otherwise accept later lines as a body and let them through). `noun` labels
|
|
6
|
+
* the value in error messages.
|
|
7
|
+
*/
|
|
8
|
+
export function singleLineSubject(
|
|
9
|
+
value: string | undefined,
|
|
10
|
+
fallback: string,
|
|
11
|
+
noun: string,
|
|
12
|
+
): string {
|
|
13
|
+
const subject = (value ?? "").trim() || fallback;
|
|
14
|
+
if (subject.length === 0) {
|
|
15
|
+
throw new Error(`No ${noun} provided and no fallback is available.`);
|
|
16
|
+
}
|
|
17
|
+
if (/[\r\n]/.test(subject)) {
|
|
18
|
+
throw new Error(`${noun} must be a single line (no line breaks).`);
|
|
19
|
+
}
|
|
20
|
+
return subject;
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { loadConfig } from "../config";
|
|
2
|
+
|
|
3
|
+
/** Validate a data-only header policy without running branch-controlled code. */
|
|
4
|
+
export function validateCommitSubject(rootDir: string, subject: string): void {
|
|
5
|
+
const policy = loadConfig(rootDir).subject;
|
|
6
|
+
if (!subject.trim() || /[\r\n]/.test(subject)) throw new Error("Commit subject must be a nonempty single line.");
|
|
7
|
+
if ([...subject].length > policy.maxLength) throw new Error(`Commit subject exceeds ${policy.maxLength} characters.`);
|
|
8
|
+
if (policy.conventional) {
|
|
9
|
+
const match = /^([a-z]+)(?:\([^\r\n()]+\))?!?: \S.*$/.exec(subject);
|
|
10
|
+
if (!match || !policy.types.includes(match[1]!)) throw new Error(`Commit subject must use a conventional type: ${policy.types.join(", ")}.`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
function portableCollisionKey(relativePath: string): string {
|
|
13
|
+
return relativePath.normalize("NFC").toLowerCase().normalize("NFC");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function destinationFor(rootDir: string, relativePath: string): string {
|
|
17
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
18
|
+
const destination = path.resolve(resolvedRoot, ...relativePath.split("/"));
|
|
19
|
+
const relativeDestination = path.relative(resolvedRoot, destination);
|
|
20
|
+
if (
|
|
21
|
+
relativeDestination === "" ||
|
|
22
|
+
relativeDestination === ".." ||
|
|
23
|
+
relativeDestination.startsWith(`..${path.sep}`) ||
|
|
24
|
+
path.isAbsolute(relativeDestination)
|
|
25
|
+
) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Unsafe destination in reviewed Git tree: ${JSON.stringify(relativePath)}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return destination;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function collision(prior: string, candidate: string): never {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Colliding paths in reviewed Git tree: ${JSON.stringify(prior)} and ${JSON.stringify(candidate)}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function nodeIdentity(destination: string): string {
|
|
40
|
+
const stats = lstatSync(destination);
|
|
41
|
+
return `${stats.dev}:${stats.ino}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function priorFilesystemAlias(
|
|
45
|
+
destination: string,
|
|
46
|
+
identities: ReadonlyMap<string, string>,
|
|
47
|
+
): string | undefined {
|
|
48
|
+
try {
|
|
49
|
+
return identities.get(nodeIdentity(destination));
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function assertExactStoredName(
|
|
56
|
+
destination: string,
|
|
57
|
+
relativePath: string,
|
|
58
|
+
): void {
|
|
59
|
+
const expectedName = Buffer.from(relativePath.split("/").at(-1) ?? "");
|
|
60
|
+
const exactNameExists = readdirSync(path.dirname(destination), {
|
|
61
|
+
encoding: "buffer",
|
|
62
|
+
}).some((name) => Buffer.compare(Buffer.from(name), expectedName) === 0);
|
|
63
|
+
if (!exactNameExists) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Destination filesystem rewrote reviewed Git path: ${JSON.stringify(relativePath)}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function probeDestinationFilesystem(
|
|
71
|
+
rootDir: string,
|
|
72
|
+
relativePaths: string[],
|
|
73
|
+
): void {
|
|
74
|
+
if (relativePaths.length === 0) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const directories = new Set<string>();
|
|
78
|
+
for (const relativePath of relativePaths) {
|
|
79
|
+
const segments = relativePath.split("/");
|
|
80
|
+
for (let end = 1; end < segments.length; end += 1) {
|
|
81
|
+
directories.add(segments.slice(0, end).join("/"));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const nodes = new Map<string, boolean>();
|
|
85
|
+
for (const directory of directories) {
|
|
86
|
+
nodes.set(directory, true);
|
|
87
|
+
}
|
|
88
|
+
for (const relativePath of relativePaths) {
|
|
89
|
+
if (nodes.has(relativePath)) {
|
|
90
|
+
collision(relativePath, relativePath);
|
|
91
|
+
}
|
|
92
|
+
nodes.set(relativePath, false);
|
|
93
|
+
}
|
|
94
|
+
const orderedNodes = [...nodes].sort(([left], [right]) => {
|
|
95
|
+
const depth = left.split("/").length - right.split("/").length;
|
|
96
|
+
return depth || Buffer.compare(Buffer.from(left), Buffer.from(right));
|
|
97
|
+
});
|
|
98
|
+
const probeRoot = mkdtempSync(
|
|
99
|
+
path.join(rootDir, ".agent-tool-destination-probe-"),
|
|
100
|
+
);
|
|
101
|
+
const identities = new Map<string, string>();
|
|
102
|
+
try {
|
|
103
|
+
for (const [relativePath, isDirectory] of orderedNodes) {
|
|
104
|
+
const destination = destinationFor(probeRoot, relativePath);
|
|
105
|
+
try {
|
|
106
|
+
if (isDirectory) {
|
|
107
|
+
mkdirSync(destination);
|
|
108
|
+
} else {
|
|
109
|
+
closeSync(openSync(destination, "wx"));
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const prior = priorFilesystemAlias(destination, identities);
|
|
113
|
+
if (prior !== undefined) {
|
|
114
|
+
collision(prior, relativePath);
|
|
115
|
+
}
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Destination filesystem rejected reviewed Git path: ${JSON.stringify(relativePath)}`,
|
|
118
|
+
{ cause: error },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const identity = nodeIdentity(destination);
|
|
122
|
+
const prior = identities.get(identity);
|
|
123
|
+
if (prior !== undefined && prior !== relativePath) {
|
|
124
|
+
collision(prior, relativePath);
|
|
125
|
+
}
|
|
126
|
+
identities.set(identity, relativePath);
|
|
127
|
+
assertExactStoredName(destination, relativePath);
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
rmSync(probeRoot, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Reject paths that alias lexically or on the actual snapshot filesystem. */
|
|
135
|
+
export function validateDestinationPaths(
|
|
136
|
+
rootDir: string,
|
|
137
|
+
relativePaths: string[],
|
|
138
|
+
): void {
|
|
139
|
+
const seenPaths = new Map<string, string>();
|
|
140
|
+
for (const relativePath of relativePaths) {
|
|
141
|
+
const segments = relativePath.split("/");
|
|
142
|
+
for (let end = 1; end <= segments.length; end += 1) {
|
|
143
|
+
const candidate = segments.slice(0, end).join("/");
|
|
144
|
+
const key = portableCollisionKey(candidate);
|
|
145
|
+
const prior = seenPaths.get(key);
|
|
146
|
+
if (prior !== undefined && prior !== candidate) {
|
|
147
|
+
collision(prior, candidate);
|
|
148
|
+
}
|
|
149
|
+
seenPaths.set(key, candidate);
|
|
150
|
+
}
|
|
151
|
+
destinationFor(rootDir, relativePath);
|
|
152
|
+
}
|
|
153
|
+
probeDestinationFilesystem(rootDir, relativePaths);
|
|
154
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { ReviewerEnv } from "./runReview";
|
|
4
|
+
|
|
5
|
+
function within(root: string, candidate: string): boolean {
|
|
6
|
+
const relative = path.relative(root, candidate);
|
|
7
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find a PATH executable whose entry and target both lie outside the
|
|
12
|
+
* repository. `candidate` keeps the PATH entry, which multi-call binaries such
|
|
13
|
+
* as `bunx` need; `resolved` is its real file.
|
|
14
|
+
*/
|
|
15
|
+
export function outsideExecutable(name: string, env: ReviewerEnv, repositoryRoot = process.cwd()): { candidate: string; resolved: string } | undefined {
|
|
16
|
+
const root = realpathSync(repositoryRoot);
|
|
17
|
+
for (const directory of (env.PATH ?? "").split(path.delimiter)) {
|
|
18
|
+
if (!path.isAbsolute(directory)) continue;
|
|
19
|
+
const candidate = path.join(directory, name);
|
|
20
|
+
try {
|
|
21
|
+
accessSync(candidate, constants.X_OK);
|
|
22
|
+
const resolved = realpathSync(candidate);
|
|
23
|
+
if (!statSync(resolved).isFile() || within(root, candidate) || within(root, resolved)) continue;
|
|
24
|
+
return { candidate, resolved };
|
|
25
|
+
} catch { /* Continue searching outside the repository. */ }
|
|
26
|
+
}
|
|
27
|
+
// Never fall back to a predictable path or PATH resolution after rejection.
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Never launch a reviewer binary supplied by the repository being reviewed. */
|
|
32
|
+
export function reviewExecutable(name: string, env: ReviewerEnv, repositoryRoot = process.cwd()): string | undefined {
|
|
33
|
+
return outsideExecutable(name, env, repositoryRoot)?.resolved;
|
|
34
|
+
}
|