@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,497 @@
|
|
|
1
|
+
import { gitEnvironment } from "./environment";
|
|
2
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
import { viewCurrentBranchPr } from "./currentBranchPr";
|
|
6
|
+
|
|
7
|
+
export interface PrIdentity {
|
|
8
|
+
readonly branch: string;
|
|
9
|
+
readonly repo: string;
|
|
10
|
+
readonly prNumber: string;
|
|
11
|
+
readonly title: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PrContext extends PrIdentity {
|
|
15
|
+
readonly baseRef: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PrView extends PrIdentity {
|
|
19
|
+
readonly baseRefName: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SpawnResult {
|
|
23
|
+
readonly status: number | null;
|
|
24
|
+
readonly signal: string | null;
|
|
25
|
+
readonly error?: Error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Large diffs blow past execFileSync's default 1 MiB maxBuffer and throw
|
|
29
|
+
// ENOBUFS before we can hand the diff to a reviewer, so capture generously.
|
|
30
|
+
export const MAX_BUFFER_BYTES = 512 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
export function run(command: string, args: string[]): string {
|
|
33
|
+
return execFileSync(command, args, {
|
|
34
|
+
env: gitEnvironment(),
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
37
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
38
|
+
}).trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function tryRun(command: string, args: string[]): string | null {
|
|
42
|
+
try {
|
|
43
|
+
return run(command, args);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function safeParse(source: string): unknown {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(source);
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fieldOf(value: unknown, key: string): unknown {
|
|
58
|
+
return typeof value === "object" && value !== null
|
|
59
|
+
? Reflect.get(value, key)
|
|
60
|
+
: undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stringField(source: string, key: string): string {
|
|
64
|
+
const value = fieldOf(safeParse(source), key);
|
|
65
|
+
return typeof value === "string" ? value : "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function firstPrNumber(source: string | null): string {
|
|
69
|
+
if (source === null) {
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
const parsed = safeParse(source);
|
|
73
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
const numberField = fieldOf(parsed[0], "number");
|
|
77
|
+
return typeof numberField === "number" ? String(numberField) : "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function gitRefExists(ref: string): boolean {
|
|
81
|
+
if (ref.length === 0) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const result = spawnSync(
|
|
85
|
+
"git",
|
|
86
|
+
["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
|
|
87
|
+
{ stdio: "ignore", env: gitEnvironment() },
|
|
88
|
+
);
|
|
89
|
+
return result.status === 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type RefExists = (ref: string) => boolean;
|
|
93
|
+
|
|
94
|
+
interface FreshBaseRefDependencies {
|
|
95
|
+
readonly fetch: (repositoryUrl: string, target: string) => string;
|
|
96
|
+
readonly refExists: RefExists;
|
|
97
|
+
readonly repositoryGitUrl: (repo: string) => string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolve an optional exact review base supplied by the coordinating skill. */
|
|
101
|
+
export function resolvePinnedReviewBase(
|
|
102
|
+
pinnedBaseOid: string | undefined,
|
|
103
|
+
refExists: RefExists = gitRefExists,
|
|
104
|
+
): string | undefined {
|
|
105
|
+
const oid = pinnedBaseOid?.trim() ?? "";
|
|
106
|
+
if (oid.length === 0) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(oid)) {
|
|
110
|
+
throw new Error("AGENT_TOOL_REVIEW_BASE_OID must be a full Git OID.");
|
|
111
|
+
}
|
|
112
|
+
if (!refExists(oid)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Pinned review base '${oid}' is unavailable locally. Fetch it and retry.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return oid;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Select a repository fetch URL using the protocol configured for `gh`. */
|
|
121
|
+
export function selectRepositoryGitUrl(
|
|
122
|
+
protocol: string,
|
|
123
|
+
httpsUrl: string,
|
|
124
|
+
sshUrl: string,
|
|
125
|
+
): string {
|
|
126
|
+
if (protocol === "https" && httpsUrl.length > 0) {
|
|
127
|
+
return httpsUrl;
|
|
128
|
+
}
|
|
129
|
+
if (protocol === "ssh" && sshUrl.length > 0) {
|
|
130
|
+
return sshUrl;
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`Unsupported or unavailable git protocol '${protocol}'.`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resolveRepositoryGitUrl(repo: string): string {
|
|
136
|
+
const repoRaw = run("gh", ["repo", "view", repo, "--json", "url,sshUrl"]);
|
|
137
|
+
const httpsUrl = stringField(repoRaw, "url");
|
|
138
|
+
const sshUrl = stringField(repoRaw, "sshUrl");
|
|
139
|
+
let host = "";
|
|
140
|
+
try {
|
|
141
|
+
host = new URL(httpsUrl).host;
|
|
142
|
+
} catch {
|
|
143
|
+
throw new Error(`Could not resolve the GitHub host for '${repo}'.`);
|
|
144
|
+
}
|
|
145
|
+
const protocol = run("gh", ["config", "get", "git_protocol", "--host", host]);
|
|
146
|
+
return selectRepositoryGitUrl(protocol, httpsUrl, sshUrl);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The line of an `ls-remote` listing naming exactly `refs/heads/<branch>`. */
|
|
150
|
+
export function parseRemoteBranchHead(
|
|
151
|
+
listing: string,
|
|
152
|
+
branch: string,
|
|
153
|
+
): string | null {
|
|
154
|
+
for (const line of listing.split("\n")) {
|
|
155
|
+
const [oid = "", ref = ""] = line.trim().split(/\s+/);
|
|
156
|
+
if (ref === `refs/heads/${branch}` && oid.length > 0) return oid;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The commit a branch points to on the repository, or null when absent. */
|
|
162
|
+
export function remoteBranchHead(
|
|
163
|
+
repositoryUrl: string,
|
|
164
|
+
branch: string,
|
|
165
|
+
): string | null {
|
|
166
|
+
return parseRemoteBranchHead(
|
|
167
|
+
run("git", ["ls-remote", "--heads", repositoryUrl, `refs/heads/${branch}`]),
|
|
168
|
+
branch,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Build a fetch that records the requested branch in a caller-owned ref. */
|
|
173
|
+
export function buildBaseFetchArgs(
|
|
174
|
+
repositoryUrl: string,
|
|
175
|
+
target: string,
|
|
176
|
+
temporaryRef: string,
|
|
177
|
+
): string[] {
|
|
178
|
+
return [
|
|
179
|
+
"fetch",
|
|
180
|
+
"--quiet",
|
|
181
|
+
"--no-tags",
|
|
182
|
+
repositoryUrl,
|
|
183
|
+
`+${target}:${temporaryRef}`,
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function fetchBase(repositoryUrl: string, target: string): string {
|
|
188
|
+
const temporaryRef = `refs/codex/review-base/${process.pid}-${randomUUID()}`;
|
|
189
|
+
try {
|
|
190
|
+
const result = spawnSync(
|
|
191
|
+
"git",
|
|
192
|
+
buildBaseFetchArgs(repositoryUrl, target, temporaryRef),
|
|
193
|
+
{ stdio: "ignore", env: gitEnvironment() },
|
|
194
|
+
);
|
|
195
|
+
if (spawnExitCode("git fetch", result) !== 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Could not fetch base '${target}' from '${repositoryUrl}'.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return run("git", ["rev-parse", `${temporaryRef}^{commit}`]);
|
|
201
|
+
} finally {
|
|
202
|
+
spawnSync("git", ["update-ref", "-d", temporaryRef], {
|
|
203
|
+
stdio: "ignore",
|
|
204
|
+
env: gitEnvironment(),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const freshBaseRefDependencies: FreshBaseRefDependencies = {
|
|
210
|
+
fetch: fetchBase,
|
|
211
|
+
refExists: gitRefExists,
|
|
212
|
+
repositoryGitUrl: resolveRepositoryGitUrl,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Base ref for a review, resolved to the exact base snapshot from the repository
|
|
217
|
+
* that owns the PR. A branch cut from an older base would otherwise diff in
|
|
218
|
+
* upstream commits it never authored — so a review (or repair round) could flag
|
|
219
|
+
* or touch code the branch does not own.
|
|
220
|
+
*/
|
|
221
|
+
export function resolveFreshBaseRef(
|
|
222
|
+
repo: string,
|
|
223
|
+
baseRefName: string,
|
|
224
|
+
dependencies: FreshBaseRefDependencies = freshBaseRefDependencies,
|
|
225
|
+
): string {
|
|
226
|
+
const fetchedOid = dependencies.fetch(
|
|
227
|
+
dependencies.repositoryGitUrl(repo),
|
|
228
|
+
`refs/heads/${baseRefName}`,
|
|
229
|
+
);
|
|
230
|
+
if (!dependencies.refExists(fetchedOid)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`Fetched base '${fetchedOid}' from '${repo}' is unavailable locally.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return fetchedOid;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Prefer an already-fetched pinned base over any new repository lookup. */
|
|
239
|
+
export function selectReviewBaseRef(
|
|
240
|
+
pinnedBaseRef: string | undefined,
|
|
241
|
+
repo: string,
|
|
242
|
+
baseRefName: string,
|
|
243
|
+
dependencies: FreshBaseRefDependencies = freshBaseRefDependencies,
|
|
244
|
+
): string {
|
|
245
|
+
return pinnedBaseRef ?? resolveFreshBaseRef(repo, baseRefName, dependencies);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Map a spawnSync result to a process exit code. A failure to launch (missing
|
|
250
|
+
* binary) or a signal termination leaves `status` null; treat those as a
|
|
251
|
+
* nonzero exit so callers can detect the failure and fall back.
|
|
252
|
+
*/
|
|
253
|
+
export function spawnExitCode(command: string, result: SpawnResult): number {
|
|
254
|
+
if (result.error) {
|
|
255
|
+
process.stderr.write(`Failed to run ${command}: ${result.error.message}\n`);
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
if (result.signal !== null) {
|
|
259
|
+
process.stderr.write(`${command} terminated by signal ${result.signal}\n`);
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
return result.status ?? 1;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function defaultBranchName(source: string): string {
|
|
266
|
+
const ref = fieldOf(safeParse(source), "defaultBranchRef");
|
|
267
|
+
const name = fieldOf(ref, "name");
|
|
268
|
+
return typeof name === "string" ? name : "";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolve the current git branch, GitHub repo, and the repo's default branch.
|
|
273
|
+
* Throws when on the default branch (or a conventional `main`/`master`) or when
|
|
274
|
+
* the repo can't be determined (gh not authenticated).
|
|
275
|
+
*/
|
|
276
|
+
export function resolveRepoContext(): {
|
|
277
|
+
branch: string;
|
|
278
|
+
repo: string;
|
|
279
|
+
defaultBranch: string;
|
|
280
|
+
} {
|
|
281
|
+
const branch = run("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
282
|
+
|
|
283
|
+
const infoRaw = tryRun("gh", [
|
|
284
|
+
"repo",
|
|
285
|
+
"view",
|
|
286
|
+
"--json",
|
|
287
|
+
"nameWithOwner,defaultBranchRef",
|
|
288
|
+
]);
|
|
289
|
+
const repo = infoRaw === null ? "" : stringField(infoRaw, "nameWithOwner");
|
|
290
|
+
if (repo.length === 0) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
"Could not determine repository. Ensure gh is authenticated.",
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const defaultBranch = infoRaw === null ? "" : defaultBranchName(infoRaw);
|
|
296
|
+
|
|
297
|
+
if (branch === defaultBranch || branch === "main" || branch === "master") {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`Cannot run this on the default branch ('${branch}'). Checkout a feature branch first.`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { branch, repo, defaultBranch };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Number of the open PR for `branch`, or "" when the query **succeeded** and
|
|
308
|
+
* found none. A failed `gh` call (auth, rate limit, network) throws rather than
|
|
309
|
+
* reporting "" — callers use the empty string to mean "no PR yet" and would
|
|
310
|
+
* otherwise pick the wrong review base or skip a duplicate-PR guard on a
|
|
311
|
+
* transient failure.
|
|
312
|
+
*/
|
|
313
|
+
export function findOpenPrNumber(branch: string, repo: string): string {
|
|
314
|
+
const raw = tryRun("gh", [
|
|
315
|
+
"pr",
|
|
316
|
+
"list",
|
|
317
|
+
"--head",
|
|
318
|
+
branch,
|
|
319
|
+
"--state",
|
|
320
|
+
"open",
|
|
321
|
+
"--json",
|
|
322
|
+
"number",
|
|
323
|
+
"-R",
|
|
324
|
+
repo,
|
|
325
|
+
]);
|
|
326
|
+
if (raw === null) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Could not list open PRs for branch '${branch}'. Ensure gh is authenticated and reachable.`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
return firstPrNumber(raw);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Read a known-open PR's title and base identity from GitHub. */
|
|
335
|
+
function viewPr(branch: string, repo: string, prNumber: string): PrView {
|
|
336
|
+
const viewRaw = run("gh", [
|
|
337
|
+
"pr",
|
|
338
|
+
"view",
|
|
339
|
+
prNumber,
|
|
340
|
+
"--json",
|
|
341
|
+
"title,baseRefName",
|
|
342
|
+
"-R",
|
|
343
|
+
repo,
|
|
344
|
+
]);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
branch,
|
|
348
|
+
repo,
|
|
349
|
+
prNumber,
|
|
350
|
+
title: stringField(viewRaw, "title"),
|
|
351
|
+
baseRefName: stringField(viewRaw, "baseRefName"),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface ReviewContextDependencies {
|
|
356
|
+
readonly findOpenPrNumber: typeof findOpenPrNumber;
|
|
357
|
+
readonly pinnedBaseRefName: string | undefined;
|
|
358
|
+
readonly pinnedBaseOid: string | undefined;
|
|
359
|
+
readonly resolvePinnedReviewBase: typeof resolvePinnedReviewBase;
|
|
360
|
+
readonly resolveRepoContext: typeof resolveRepoContext;
|
|
361
|
+
readonly selectReviewBaseRef: typeof selectReviewBaseRef;
|
|
362
|
+
readonly viewCurrentBranchPr: typeof viewCurrentBranchPr;
|
|
363
|
+
readonly viewPr: typeof viewPr;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Require a coordinator's named review target to match current GitHub state. */
|
|
367
|
+
export function assertPinnedReviewBaseRef(
|
|
368
|
+
pinnedBaseRefName: string | undefined,
|
|
369
|
+
actualBaseRefName: string,
|
|
370
|
+
): void {
|
|
371
|
+
const expected = pinnedBaseRefName?.trim() ?? "";
|
|
372
|
+
if (expected.length > 0 && expected !== actualBaseRefName) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Review base changed from pinned branch '${expected}' to '${actualBaseRefName}'.`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Resolve the open PR for the current branch from git + GitHub. Throws with an
|
|
381
|
+
* actionable message when there is nothing reviewable (on main, no PR, gh not
|
|
382
|
+
* authenticated).
|
|
383
|
+
*/
|
|
384
|
+
function fetchPrView(): PrView {
|
|
385
|
+
const { branch, repo } = resolveRepoContext();
|
|
386
|
+
|
|
387
|
+
const currentBranchPr = viewCurrentBranchPr(branch);
|
|
388
|
+
if (currentBranchPr !== undefined) {
|
|
389
|
+
return currentBranchPr;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const prNumber = findOpenPrNumber(branch, repo);
|
|
393
|
+
if (prNumber.length === 0) {
|
|
394
|
+
throw new Error(`No PR found for branch '${branch}'. Create a PR first.`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return viewPr(branch, repo, prNumber);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Current GitHub state of a PR (e.g. "OPEN", "MERGED", "CLOSED"). */
|
|
401
|
+
export function prState(prNumber: string, repo: string): string {
|
|
402
|
+
return stringField(
|
|
403
|
+
run("gh", ["pr", "view", prNumber, "--json", "state", "-R", repo]),
|
|
404
|
+
"state",
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Identity of the open PR for the current branch (no base-ref resolution). */
|
|
409
|
+
export function resolvePr(): PrIdentity {
|
|
410
|
+
const view = fetchPrView();
|
|
411
|
+
return {
|
|
412
|
+
branch: view.branch,
|
|
413
|
+
repo: view.repo,
|
|
414
|
+
prNumber: view.prNumber,
|
|
415
|
+
title: view.title,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* PR identity plus a base ref that `git diff` can resolve locally, for a review
|
|
421
|
+
* that may run *before* the branch has a PR.
|
|
422
|
+
*
|
|
423
|
+
* When an open PR exists this matches what a post-open review would see: the
|
|
424
|
+
* base ref is the PR's own base, resolved to its current remote tip. When none
|
|
425
|
+
* exists yet, `prNumber`/`title` are empty and the base ref is the repository's
|
|
426
|
+
* current default branch — so the identical `base...HEAD` diff can be reviewed on
|
|
427
|
+
* the local branch now and reviewed again once its PR is open. Either way the base
|
|
428
|
+
* is fetched fresh, so a stale local ref never leaks upstream commits into the
|
|
429
|
+
* diff. Throws on the default branch, via `resolveRepoContext`.
|
|
430
|
+
*/
|
|
431
|
+
export function resolveReviewContext(
|
|
432
|
+
dependencies?: ReviewContextDependencies,
|
|
433
|
+
): PrContext {
|
|
434
|
+
const {
|
|
435
|
+
AGENT_TOOL_REVIEW_BASE_OID: pinnedBaseOid,
|
|
436
|
+
AGENT_TOOL_REVIEW_BASE_REF: pinnedBaseRefName,
|
|
437
|
+
} = process.env;
|
|
438
|
+
const deps = dependencies ?? {
|
|
439
|
+
findOpenPrNumber,
|
|
440
|
+
pinnedBaseRefName,
|
|
441
|
+
pinnedBaseOid,
|
|
442
|
+
resolvePinnedReviewBase,
|
|
443
|
+
resolveRepoContext,
|
|
444
|
+
selectReviewBaseRef,
|
|
445
|
+
viewCurrentBranchPr,
|
|
446
|
+
viewPr,
|
|
447
|
+
};
|
|
448
|
+
const {
|
|
449
|
+
branch,
|
|
450
|
+
repo: checkoutRepo,
|
|
451
|
+
defaultBranch,
|
|
452
|
+
} = deps.resolveRepoContext();
|
|
453
|
+
const pinnedBaseRef = deps.resolvePinnedReviewBase(deps.pinnedBaseOid);
|
|
454
|
+
|
|
455
|
+
const currentBranchPr = deps.viewCurrentBranchPr(branch);
|
|
456
|
+
const prNumber =
|
|
457
|
+
currentBranchPr?.prNumber ?? deps.findOpenPrNumber(branch, checkoutRepo);
|
|
458
|
+
if (prNumber.length === 0) {
|
|
459
|
+
if (defaultBranch.length === 0) {
|
|
460
|
+
throw new Error(
|
|
461
|
+
"Could not determine the repository default branch to review against.",
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
assertPinnedReviewBaseRef(deps.pinnedBaseRefName, defaultBranch);
|
|
465
|
+
// No PR yet: review the local branch against the *current* remote default,
|
|
466
|
+
// fetched fresh so a stale local ref cannot pull unrelated upstream commits
|
|
467
|
+
// into the diff.
|
|
468
|
+
return {
|
|
469
|
+
branch,
|
|
470
|
+
repo: checkoutRepo,
|
|
471
|
+
prNumber: "",
|
|
472
|
+
title: "",
|
|
473
|
+
baseRef: deps.selectReviewBaseRef(
|
|
474
|
+
pinnedBaseRef,
|
|
475
|
+
checkoutRepo,
|
|
476
|
+
defaultBranch,
|
|
477
|
+
),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const view = currentBranchPr ?? deps.viewPr(branch, checkoutRepo, prNumber);
|
|
482
|
+
if (view.baseRefName.length === 0) {
|
|
483
|
+
throw new Error("Could not determine base branch from GitHub.");
|
|
484
|
+
}
|
|
485
|
+
assertPinnedReviewBaseRef(deps.pinnedBaseRefName, view.baseRefName);
|
|
486
|
+
return {
|
|
487
|
+
branch,
|
|
488
|
+
repo: view.repo,
|
|
489
|
+
prNumber,
|
|
490
|
+
title: view.title,
|
|
491
|
+
baseRef: deps.selectReviewBaseRef(
|
|
492
|
+
pinnedBaseRef,
|
|
493
|
+
view.repo,
|
|
494
|
+
view.baseRefName,
|
|
495
|
+
),
|
|
496
|
+
};
|
|
497
|
+
}
|
package/src/harnesses.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type PrContext, resolveReviewContext, run } from "./git/prContext";
|
|
2
|
+
import { withPinnedReviewInput } from "./review/pinnedReviewInput";
|
|
3
|
+
import { resolveReviewEffort, type ReviewEffort } from "./review/reviewEffort";
|
|
4
|
+
import { buildReviewPrompt, CLAUDE_ACCESS_NOTE, CODEX_ACCESS_NOTE, OPENCODE_ACCESS_NOTE } from "./review/reviewPrompt";
|
|
5
|
+
import { spawnClaudeReview } from "./review/solicitClaudeCodeReview";
|
|
6
|
+
import { spawnCodexReview } from "./review/solicitCodexReview";
|
|
7
|
+
import { spawnOpencodeReview } from "./review/solicitOpencodeReview";
|
|
8
|
+
|
|
9
|
+
export interface Harness {
|
|
10
|
+
name: "claude" | "codex" | "opencode";
|
|
11
|
+
executable: string;
|
|
12
|
+
skillDirectory: string;
|
|
13
|
+
defaultEffort: ReviewEffort;
|
|
14
|
+
accessNote: string;
|
|
15
|
+
review: (prompt: string, effort: ReviewEffort, snapshotRoot: string) => number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Harness-specific discovery and invocation stay behind this small boundary. */
|
|
19
|
+
export const HARNESSES: readonly Harness[] = [
|
|
20
|
+
{ name: "claude", executable: "claude", skillDirectory: ".claude/skills", defaultEffort: "xhigh", accessNote: CLAUDE_ACCESS_NOTE, review: spawnClaudeReview },
|
|
21
|
+
{ name: "codex", executable: "codex", skillDirectory: ".agents/skills", defaultEffort: "high", accessNote: CODEX_ACCESS_NOTE, review: spawnCodexReview },
|
|
22
|
+
{ name: "opencode", executable: "opencode", skillDirectory: ".opencode/skills", defaultEffort: "high", accessNote: OPENCODE_ACCESS_NOTE, review: spawnOpencodeReview },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function harnessNamed(name: string): Harness {
|
|
26
|
+
const harness = HARNESSES.find(item => item.name === name);
|
|
27
|
+
if (!harness) throw new Error(`Unknown harness '${name}'; choose claude, codex, or opencode.`);
|
|
28
|
+
return harness;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** An explicit local base enables review without GitHub or network access. */
|
|
32
|
+
export function review(rootDir: string, name: string, effortArg?: string, base?: string): number {
|
|
33
|
+
const harness = harnessNamed(name);
|
|
34
|
+
const effort = resolveReviewEffort(effortArg, harness.defaultEffort);
|
|
35
|
+
const context: PrContext = base === undefined ? resolveReviewContext() : {
|
|
36
|
+
branch: run("git", ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
37
|
+
repo: rootDir,
|
|
38
|
+
prNumber: "",
|
|
39
|
+
title: "",
|
|
40
|
+
baseRef: run("git", ["rev-parse", "--verify", "--end-of-options", `${base}^{commit}`]),
|
|
41
|
+
};
|
|
42
|
+
return withPinnedReviewInput(rootDir, context, input => {
|
|
43
|
+
const exitCode = harness.review(buildReviewPrompt({
|
|
44
|
+
context,
|
|
45
|
+
diff: input.diff,
|
|
46
|
+
reviewInstructions: input.reviewInstructions,
|
|
47
|
+
accessNote: harness.accessNote,
|
|
48
|
+
repositoryRoot: input.snapshotRoot,
|
|
49
|
+
}), effort, input.snapshotRoot);
|
|
50
|
+
if (run("git", ["rev-parse", "HEAD"]) !== input.headCommit) throw new Error("HEAD changed during review; review the new commit again.");
|
|
51
|
+
return exitCode;
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gh pr create` needs the head branch on the repository at the commit being
|
|
3
|
+
* proposed, and this tool never pushes: a push runs the pre-push gate, which
|
|
4
|
+
* the caller owns. Refuse clearly rather than let `gh` report a blank head.
|
|
5
|
+
*/
|
|
6
|
+
export function assertBranchPushed(input: {
|
|
7
|
+
readonly branch: string;
|
|
8
|
+
readonly localHead: string;
|
|
9
|
+
readonly remoteHead: string | null;
|
|
10
|
+
}): void {
|
|
11
|
+
if (input.remoteHead === null) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Branch '${input.branch}' is not on the repository. openPr does not push; run 'git push -u origin ${input.branch}' first.`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
if (input.remoteHead !== input.localHead) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Branch '${input.branch}' is at ${input.remoteHead} on the repository but ${input.localHead} locally. Fetch and reconcile, then push (never force) before opening the PR.`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branding signatures that Claude Code injects into commit/PR footers. These
|
|
3
|
+
* target the attribution itself -- the "Generated with Claude Code" line, its
|
|
4
|
+
* claude.com/claude-code and claude.ai/code links, and Claude co-author
|
|
5
|
+
* trailers -- not every prose mention of the words, so a PR that legitimately
|
|
6
|
+
* documents the repo's own Claude tooling is not blocked.
|
|
7
|
+
*/
|
|
8
|
+
const CLAUDE_BRANDING_PATTERNS: ReadonlyArray<{
|
|
9
|
+
readonly label: string;
|
|
10
|
+
readonly pattern: RegExp;
|
|
11
|
+
}> = [
|
|
12
|
+
{
|
|
13
|
+
label:
|
|
14
|
+
"Claude Code attribution link (claude.com/claude-code or claude.ai/code)",
|
|
15
|
+
pattern: /claude\.(?:com\/claude-code|ai\/code)/i,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
label: '"Generated with Claude Code" attribution',
|
|
19
|
+
pattern: /generated\s+with\s+\[?\s*claude\s+code/i,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
label: "Co-authored-by Claude trailer",
|
|
23
|
+
pattern: /co-authored-by:\s*claude\b/i,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
label: "Anthropic no-reply co-author address (noreply@anthropic.com)",
|
|
27
|
+
pattern: /\bnoreply@anthropic\.com\b/i,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reject a PR body that carries Claude Code branding. The pre-push hook already
|
|
33
|
+
* strips Co-authored-by trailers from commits; this applies the same policy to
|
|
34
|
+
* PR descriptions, where memory and prompt guidance alone have not held. Throws
|
|
35
|
+
* with the matched signals when branding is present; returns silently otherwise.
|
|
36
|
+
*/
|
|
37
|
+
export function assertNoClaudeBranding(body: string): void {
|
|
38
|
+
const matched = CLAUDE_BRANDING_PATTERNS.filter(({ pattern }) =>
|
|
39
|
+
pattern.test(body),
|
|
40
|
+
);
|
|
41
|
+
if (matched.length === 0) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const labels = matched.map(({ label }) => ` - ${label}`).join("\n");
|
|
46
|
+
throw new Error(
|
|
47
|
+
`PR body contains Claude Code branding:\n${labels}\n\n` +
|
|
48
|
+
"Remove every Claude Code attribution line, co-author trailer, and " +
|
|
49
|
+
"claude.com/claude-code or claude.ai/code link from the PR description, " +
|
|
50
|
+
"then re-run open-pr.",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { run } from "../git/prContext";
|
|
2
|
+
|
|
3
|
+
function field(value: unknown, key: string): unknown {
|
|
4
|
+
return typeof value === "object" && value !== null
|
|
5
|
+
? Reflect.get(value, key)
|
|
6
|
+
: undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function reason(error: unknown): string {
|
|
10
|
+
return error instanceof Error ? error.message : String(error);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function strictStatusRule(rule: unknown): boolean {
|
|
14
|
+
const parameters = field(rule, "parameters");
|
|
15
|
+
const checks = field(parameters, "required_status_checks");
|
|
16
|
+
return field(rule, "type") === "required_status_checks" &&
|
|
17
|
+
field(parameters, "strict_required_status_checks_policy") === true &&
|
|
18
|
+
Array.isArray(checks) && checks.length > 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* GitHub's merge mutation has no expected-base input. An active strict status
|
|
23
|
+
* rule that the merging actor cannot bypass makes GitHub itself reject a head
|
|
24
|
+
* that lacks the latest base, closing the race after the final freshness check.
|
|
25
|
+
*/
|
|
26
|
+
export function assertStrictBaseFreshness(
|
|
27
|
+
repo: string,
|
|
28
|
+
baseRef: string,
|
|
29
|
+
read: typeof run = run,
|
|
30
|
+
): void {
|
|
31
|
+
const where = `${repo}:${baseRef}`;
|
|
32
|
+
let rulesetIds: unknown[];
|
|
33
|
+
try {
|
|
34
|
+
// --jq emits one rule per line across every page.
|
|
35
|
+
rulesetIds = [...new Set(
|
|
36
|
+
read("gh", ["api", "--paginate", `repos/${repo}/rules/branches/${encodeURIComponent(baseRef)}`, "--jq", ".[]"])
|
|
37
|
+
.split("\n")
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map((line): unknown => JSON.parse(line))
|
|
40
|
+
.filter(strictStatusRule)
|
|
41
|
+
.map((rule) => field(rule, "ruleset_id")),
|
|
42
|
+
)];
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error(`Could not read effective rules for ${where}; refusing to merge. ${reason(error)}`);
|
|
45
|
+
}
|
|
46
|
+
if (rulesetIds.length === 0) {
|
|
47
|
+
throw new Error(`${where} has no strict required status check rule; refusing to merge.`);
|
|
48
|
+
}
|
|
49
|
+
for (const id of rulesetIds) {
|
|
50
|
+
if (!Number.isSafeInteger(id)) continue;
|
|
51
|
+
let ruleset: unknown;
|
|
52
|
+
try {
|
|
53
|
+
ruleset = JSON.parse(read("gh", ["api", `repos/${repo}/rulesets/${id}`]));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new Error(`Could not verify ruleset ${id} for ${where}; refusing to merge. ${reason(error)}`);
|
|
56
|
+
}
|
|
57
|
+
const rules = field(ruleset, "rules");
|
|
58
|
+
if (
|
|
59
|
+
field(ruleset, "enforcement") === "active" &&
|
|
60
|
+
field(ruleset, "current_user_can_bypass") === "never" &&
|
|
61
|
+
Array.isArray(rules) && rules.some(strictStatusRule)
|
|
62
|
+
) return;
|
|
63
|
+
}
|
|
64
|
+
throw new Error(
|
|
65
|
+
`The authenticated actor can bypass strict base freshness for ${where}, or no strict ruleset is active; refusing to merge.`,
|
|
66
|
+
);
|
|
67
|
+
}
|