@a2f0/agent-tool 0.1.47 → 0.1.49
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/package.json +1 -1
- package/src/cli.ts +2 -3
- package/src/git/prContext.ts +3 -2
- package/src/release/commands.ts +3 -1
- package/src/runAgentToolAction.ts +3 -2
- package/src/system/boundedCalls.ts +44 -0
- package/src/workflows/core.ts +6 -4
- package/src/workflows/pr.ts +19 -2
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -16,7 +16,6 @@ import { bumpVersions, checkVersions, planVersions } from "./version/bumpVersion
|
|
|
16
16
|
import { prepareVersions } from "./version/prepareVersions";
|
|
17
17
|
import { resolveVersionConflicts } from "./version/resolveVersionConflicts";
|
|
18
18
|
import { openPr } from "./pr/openPr";
|
|
19
|
-
import { squashMerge } from "./pr/squashMerge";
|
|
20
19
|
import metadata from "../package.json";
|
|
21
20
|
import { runCommand } from "./commands/run";
|
|
22
21
|
import { inspectLog } from "./commands/output";
|
|
@@ -25,7 +24,7 @@ import { branchCommand } from "./workflows/branch";
|
|
|
25
24
|
import { commitsCommand, binariesCommand, gateCommand } from "./workflows/checks";
|
|
26
25
|
import { hooksCommand } from "./workflows/hooks";
|
|
27
26
|
import { repoCommand, policyCommand, toolchainCommand } from "./workflows/repository";
|
|
28
|
-
import { prWorkflowCommand } from "./workflows/pr";
|
|
27
|
+
import { mergeReviewedHead, prWorkflowCommand } from "./workflows/pr";
|
|
29
28
|
import { releaseCommand } from "./release/commands";
|
|
30
29
|
import { checkoutRoot } from "./workflows/core";
|
|
31
30
|
import { procCommand, teardownCommand } from "./processes/commands";
|
|
@@ -251,7 +250,7 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
|
|
|
251
250
|
}
|
|
252
251
|
usage(action === "merge" && args.length === 3, "Usage: agent-tool pr merge <subject-or-empty> <reviewed-head-oid> <base-branch>");
|
|
253
252
|
usage(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(args[1]!), "Merge requires a full reviewed HEAD OID.");
|
|
254
|
-
return
|
|
253
|
+
return mergeReviewedHead(root, args[0], args[1], args[2]);
|
|
255
254
|
}
|
|
256
255
|
if (command === "versions") {
|
|
257
256
|
const action = args.shift();
|
package/src/git/prContext.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { gitEnvironment } from "./environment";
|
|
2
|
-
import {
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { boundedExecFileSync } from "../system/boundedCalls";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
|
|
5
6
|
import { viewCurrentBranchPr } from "./currentBranchPr";
|
|
@@ -30,7 +31,7 @@ interface SpawnResult {
|
|
|
30
31
|
export const MAX_BUFFER_BYTES = 512 * 1024 * 1024;
|
|
31
32
|
|
|
32
33
|
export function run(command: string, args: string[]): string {
|
|
33
|
-
return
|
|
34
|
+
return boundedExecFileSync(command, args, {
|
|
34
35
|
env: gitEnvironment(),
|
|
35
36
|
encoding: "utf8",
|
|
36
37
|
stdio: ["ignore", "pipe", "pipe"],
|
package/src/release/commands.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { decidePublish, readRegistryState } from "./npm";
|
|
|
5
5
|
import { assert, flag, json, oid, option, output, poll, repository, timeoutOptions } from "../workflows/core";
|
|
6
6
|
|
|
7
7
|
function packageName(name: unknown): asserts name is string { assert(typeof name === "string" && /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(name), "Invalid npm package name."); }
|
|
8
|
+
export const RELEASE_TIMEOUT_SECONDS = 3600;
|
|
8
9
|
export interface ReleaseRun { databaseId: number; status: string; conclusion: string; headSha: string; url: string }
|
|
9
10
|
/**
|
|
10
11
|
* Downloads and checks the published tarball. Resolves null only when the
|
|
@@ -64,7 +65,8 @@ export async function releaseCommand(args: string[]): Promise<number> {
|
|
|
64
65
|
output({ name: manifest.name, version: manifest.version, privateSource: manifest.private === true, ...decision }); return 0;
|
|
65
66
|
}
|
|
66
67
|
const workflow = option(args, "--workflow");
|
|
67
|
-
|
|
68
|
+
// Publishing runs commonly take 35-45 minutes before npm propagation, so wait an hour unless told otherwise.
|
|
69
|
+
const options = timeoutOptions(args, RELEASE_TIMEOUT_SECONDS);
|
|
68
70
|
assert(action === "verify" && args.length === 3 && workflow, "Usage: release verify <package> <version> <merge-oid> --workflow <name> [--timeout seconds] [--interval seconds]");
|
|
69
71
|
const [name, version, commit] = args as [string, string, string];
|
|
70
72
|
packageName(name); oid(commit);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { openPr } from "./pr/openPr";
|
|
2
|
-
import { squashMerge } from "./pr/squashMerge";
|
|
3
2
|
import { solicitClaudeCodeReview } from "./review/solicitClaudeCodeReview";
|
|
4
3
|
import { solicitCodexReview } from "./review/solicitCodexReview";
|
|
5
4
|
import { solicitOpencodeReview } from "./review/solicitOpencodeReview";
|
|
6
5
|
import { bumpVersions, checkVersions } from "./version/bumpVersions";
|
|
7
6
|
import { resolveVersionConflicts } from "./version/resolveVersionConflicts";
|
|
7
|
+
import { mergeReviewedHead } from "./workflows/pr";
|
|
8
8
|
|
|
9
9
|
const AGENT_TOOL_USAGE =
|
|
10
10
|
"Usage: agent-tool <solicitClaudeCodeReview|solicitCodexReview|solicitOpencodeReview|openPr|squashMerge|bumpVersions|checkVersions|resolveVersionConflicts> [args]\n";
|
|
@@ -36,7 +36,8 @@ const defaultActions: AgentToolActions = {
|
|
|
36
36
|
solicitClaudeCodeReview,
|
|
37
37
|
solicitCodexReview,
|
|
38
38
|
solicitOpencodeReview,
|
|
39
|
-
|
|
39
|
+
// A reviewed HEAD merges only after every preflight gate passes.
|
|
40
|
+
squashMerge: mergeReviewedHead,
|
|
40
41
|
};
|
|
41
42
|
|
|
42
43
|
function assertMaximumPositionals(
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bounds for each external command a wait runs. A poll checks its deadline only
|
|
6
|
+
* between inspections, so one hung `gh` or `git ls-remote` would otherwise keep
|
|
7
|
+
* the wait running forever. Each call may take the time left before the
|
|
8
|
+
* deadline, but at least `minMs` so the last inspection can still finish, and
|
|
9
|
+
* never more than `maxMs`. Mutable for tests.
|
|
10
|
+
*/
|
|
11
|
+
export const CALL_LIMITS = { minMs: 15_000, maxMs: 120_000 };
|
|
12
|
+
|
|
13
|
+
// Scoped per async context, so overlapping waits never see each other's deadline.
|
|
14
|
+
const deadlines = new AsyncLocalStorage<number>();
|
|
15
|
+
|
|
16
|
+
/** Run `inspect` with every bounded call limited by `until`, the wait's deadline. */
|
|
17
|
+
export async function withCallDeadline<T>(until: number, inspect: () => T | Promise<T>): Promise<T> {
|
|
18
|
+
return await deadlines.run(until, inspect);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function currentLimit(): number | undefined {
|
|
22
|
+
const deadline = deadlines.getStore();
|
|
23
|
+
if (deadline === undefined) return undefined;
|
|
24
|
+
return Math.min(CALL_LIMITS.maxMs, Math.max(CALL_LIMITS.minMs, deadline - Date.now()));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `execFileSync` that, inside a wait, is killed and fails once its call limit
|
|
29
|
+
* passes. Bun returns as soon as the command itself is killed, even when a
|
|
30
|
+
* descendant such as ssh still holds its output pipes; that descendant is left
|
|
31
|
+
* to exit on its own, as it would after an interrupted command.
|
|
32
|
+
*/
|
|
33
|
+
export function boundedExecFileSync(command: string, args: string[], options: ExecFileSyncOptionsWithStringEncoding): string {
|
|
34
|
+
const timeout = currentLimit();
|
|
35
|
+
try {
|
|
36
|
+
return execFileSync(command, args, timeout === undefined ? options : { ...options, timeout, killSignal: "SIGKILL" });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const failure = error as NodeJS.ErrnoException;
|
|
39
|
+
if (timeout !== undefined && failure.code === "ETIMEDOUT") {
|
|
40
|
+
throw new Error(`${[command, ...args.slice(0, 2)].join(" ")} did not finish within ${Math.ceil(timeout / 1000)} seconds; stopped it.`);
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/workflows/core.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { lstatSync, realpathSync, writeSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { gitEnvironment } from "../git/environment";
|
|
5
|
+
import { boundedExecFileSync, withCallDeadline } from "../system/boundedCalls";
|
|
5
6
|
|
|
6
7
|
export function output(value: unknown): void { writeSync(1, `${JSON.stringify(value, null, 2)}\n`); }
|
|
7
8
|
export function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); }
|
|
8
9
|
export function oid(value: string): string { assert(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(value), "Expected a full commit OID."); return value; }
|
|
9
10
|
export function read(command: string, args: string[], cwd = process.cwd()): string {
|
|
10
|
-
return
|
|
11
|
+
return boundedExecFileSync(command, args, { cwd, env: gitEnvironment(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 }).trimEnd();
|
|
11
12
|
}
|
|
12
13
|
export function git(args: string[], cwd = process.cwd()): string { return read("git", args, cwd); }
|
|
13
14
|
export function json<T>(args: string[]): T { return JSON.parse(read("gh", args)) as T; }
|
|
@@ -88,9 +89,10 @@ export function timeoutOptions(args: string[], defaultSeconds = 900): { timeoutM
|
|
|
88
89
|
export async function poll<T>(inspect: () => { done: boolean; value: T } | Promise<{ done: boolean; value: T }>, options: { timeoutMs: number; intervalMs: number }): Promise<T> {
|
|
89
90
|
const deadline = Date.now() + options.timeoutMs;
|
|
90
91
|
while (true) {
|
|
91
|
-
|
|
92
|
+
// Each external call inside an inspection is bounded too, so a hung one cannot outlast the wait.
|
|
93
|
+
const result = await withCallDeadline(deadline, inspect);
|
|
92
94
|
if (result.done) return result.value;
|
|
93
|
-
assert(Date.now() < deadline, `Timed out waiting: ${JSON.stringify(result.value)}
|
|
95
|
+
assert(Date.now() < deadline, `Timed out waiting: ${JSON.stringify(result.value)} Pass --timeout <seconds> to wait longer.`);
|
|
94
96
|
await new Promise(resolve => setTimeout(resolve, Math.min(options.intervalMs, deadline - Date.now())));
|
|
95
97
|
}
|
|
96
98
|
}
|
package/src/workflows/pr.ts
CHANGED
|
@@ -4,11 +4,11 @@ import { closeSync, readFileSync } from "node:fs";
|
|
|
4
4
|
import { createLog } from "../commands/logFile";
|
|
5
5
|
import { DEFAULT_LIMITS, logTail, outputLimit } from "../commands/output";
|
|
6
6
|
import { loadConfig } from "../config";
|
|
7
|
-
import { remoteBranchHead, resolvePr, resolveRepositoryGitUrl } from "../git/prContext";
|
|
7
|
+
import { remoteBranchHead, resolveFreshBaseRef, resolvePr, resolveRepositoryGitUrl } from "../git/prContext";
|
|
8
8
|
import { assertMergeChecks } from "../pr/mergeChecks";
|
|
9
9
|
import { assertStrictBaseFreshness } from "../pr/baseFreshness";
|
|
10
10
|
import { baseFreshness, behindReason, cancelRuns } from "./prFreshness";
|
|
11
|
-
import { resolvePullRequestMergeTarget } from "../pr/squashMerge";
|
|
11
|
+
import { resolvePullRequestMergeTarget, squashMerge } from "../pr/squashMerge";
|
|
12
12
|
import { planVersions } from "../version/bumpVersions";
|
|
13
13
|
import { assert, clean, currentBranch, git, head, json, oid, option, flag, output, poll, timeoutOptions } from "./core";
|
|
14
14
|
|
|
@@ -86,6 +86,23 @@ export function preflight(expectedHead: string, baseBranch: string, baseOid: str
|
|
|
86
86
|
return { ok: true, repo: identity.repo, pr: pr.number, url: pr.url, headOid: expectedHead, baseBranch, baseOid, checks: checks.checks };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Merge a reviewed HEAD only after every preflight gate passes against the
|
|
91
|
+
* freshly fetched live base: a skipped, failed, or stale preflight run never
|
|
92
|
+
* leads to a merge. Without a reviewed HEAD (legacy manual form) only the
|
|
93
|
+
* merge helper's own checks apply.
|
|
94
|
+
*/
|
|
95
|
+
export function mergeReviewedHead(rootDir: string, subject?: string, expectedHead?: string, baseBranch?: string): number {
|
|
96
|
+
if (!expectedHead) return squashMerge(rootDir, subject, expectedHead, baseBranch);
|
|
97
|
+
const identity = resolvePr();
|
|
98
|
+
const branch = baseBranch ?? resolvePullRequestMergeTarget(identity).baseRefName;
|
|
99
|
+
const baseOid = resolveFreshBaseRef(identity.repo, branch);
|
|
100
|
+
try { preflight(expectedHead, branch, baseOid); }
|
|
101
|
+
catch (error) { throw new Error(`Refusing to merge: preflight failed against live ${branch} ${baseOid}: ${(error as Error).message}`); }
|
|
102
|
+
// Bind the merge to the branch that passed preflight, inferred or given, so a retarget in between is refused.
|
|
103
|
+
return squashMerge(rootDir, subject, expectedHead, branch);
|
|
104
|
+
}
|
|
105
|
+
|
|
89
106
|
interface Comment { id: string; databaseId: number; body: string; url: string; author: { login: string } | null }
|
|
90
107
|
interface Page<T> { nodes: T[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } }
|
|
91
108
|
interface Thread { id: string; isResolved: boolean; isOutdated: boolean; path: string; line: number | null; comments: Page<Comment> }
|