@a2f0/agent-tool 0.1.17 → 0.1.18

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.
@@ -0,0 +1,137 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { gitEnvironment } from "../git/environment";
3
+ import { readFileSync } from "node:fs";
4
+ import { loadConfig } from "../config";
5
+ import { remoteBranchHead, resolvePr, resolveRepositoryGitUrl } from "../git/prContext";
6
+ import { assertMergeChecks } from "../pr/mergeChecks";
7
+ import { assertStrictBaseFreshness } from "../pr/baseFreshness";
8
+ import { resolvePullRequestMergeTarget } from "../pr/squashMerge";
9
+ import { planVersions } from "../version/bumpVersions";
10
+ import { assert, clean, currentBranch, git, head, json, oid, option, flag, output, poll, timeoutOptions } from "./core";
11
+
12
+ export interface PullRequest {
13
+ number: number; url: string; state: string; title: string; headRefName: string; headRefOid: string;
14
+ baseRefName: string; baseRefOid: string; isDraft: boolean; mergeable: string; mergeStateStatus: string;
15
+ isCrossRepository: boolean; mergeCommit: { oid: string } | null;
16
+ }
17
+ export function pullRequest(number?: string, repo?: string): PullRequest {
18
+ return json<PullRequest>(["pr", "view", ...(number ? [number] : []), ...(repo ? ["-R", repo] : []), "--json", "number,url,state,title,headRefName,headRefOid,baseRefName,baseRefOid,isDraft,mergeable,mergeStateStatus,isCrossRepository,mergeCommit"]);
19
+ }
20
+ interface Check { name: string; state: string; workflow: string }
21
+ export function checkSnapshot(pr: { prNumber: string; repo: string }, expected: string): { ok: boolean; waiting: boolean; headOid: string; checks: Check[]; reason: string | null } {
22
+ const initial = json<{ headRefOid: string; statusCheckRollup: unknown[] }>(["pr", "view", pr.prNumber, "-R", pr.repo, "--json", "headRefOid,statusCheckRollup"]);
23
+ assert(initial.headRefOid === expected, "PR HEAD changed while waiting; review the new commit.");
24
+ assert(Array.isArray(initial.statusCheckRollup), "Could not read checks.");
25
+ const checks = initial.statusCheckRollup.length ? json<Check[]>(["pr", "checks", pr.prNumber, "-R", pr.repo, "--json", "name,state,workflow"]) : [];
26
+ assert(Array.isArray(checks), "Could not read checks.");
27
+ const after = pullRequest(pr.prNumber, pr.repo);
28
+ assert(after.headRefOid === expected && after.state === "OPEN", "PR HEAD or state changed while waiting.");
29
+ try {
30
+ assertMergeChecks(JSON.stringify({ headRefOid: expected, checks }), expected);
31
+ return { ok: true, waiting: false, headOid: expected, checks, reason: null };
32
+ } catch (error) {
33
+ const requiredSkipped = loadConfig().merge.requiredChecks.some(required => {
34
+ const matches = checks.filter(check => check.name === required.name && (required.workflow === undefined || check.workflow === required.workflow));
35
+ return matches.length > 0 && matches.every(check => check.state === "SKIPPED");
36
+ });
37
+ const failed = requiredSkipped || checks.some(check => !["SUCCESS", "SKIPPED", "PENDING", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED", "EXPECTED"].includes(check.state));
38
+ return { ok: false, waiting: !failed, headOid: expected, checks, reason: (error as Error).message };
39
+ }
40
+ }
41
+
42
+ export function preflight(expectedHead: string, baseBranch: string, baseOid: string): unknown {
43
+ oid(expectedHead); oid(baseOid); clean();
44
+ assert(head() === expectedHead, "Local HEAD differs from reviewed HEAD.");
45
+ const identity = resolvePr();
46
+ const pr = pullRequest(identity.prNumber, identity.repo);
47
+ assert(pr.headRefOid === expectedHead && pr.headRefName === currentBranch(), "PR does not match the current branch and reviewed HEAD.");
48
+ assert(pr.state === "OPEN" && !pr.isDraft && pr.mergeable === "MERGEABLE" && pr.mergeStateStatus === "CLEAN", `PR is not ready to merge (${pr.state}/${pr.mergeable}/${pr.mergeStateStatus}).`);
49
+ const target = resolvePullRequestMergeTarget(identity, baseBranch);
50
+ assert(target.headOid === expectedHead, "PR head changed during preflight.");
51
+ assert(remoteBranchHead(resolveRepositoryGitUrl(identity.repo), baseBranch) === baseOid, "Live base differs from reviewed base; integrate, validate and review again.");
52
+ assert(git(["merge-base", expectedHead, baseOid]) === baseOid, "Reviewed HEAD does not include the reviewed base.");
53
+ const config = loadConfig();
54
+ if (config.merge.requireStrictBaseFreshness) assertStrictBaseFreshness(identity.repo, baseBranch);
55
+ const checks = checkSnapshot(identity, expectedHead);
56
+ assert(checks.ok, checks.reason ?? "Checks have not passed.");
57
+ // Explicit version policy opts in; unversioned repositories need no manifest.
58
+ if (config.versions.packages !== null) assert(planVersions(process.cwd(), baseOid).every(plan => plan.headVersion === plan.targetVersion), "Package versions are stale against the reviewed base.");
59
+ assert(head() === expectedHead, "Local HEAD changed during preflight.");
60
+ return { ok: true, repo: identity.repo, pr: pr.number, url: pr.url, headOid: expectedHead, baseBranch, baseOid, checks: checks.checks };
61
+ }
62
+
63
+ interface Comment { id: string; databaseId: number; body: string; url: string; author: { login: string } | null }
64
+ interface Page<T> { nodes: T[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } }
65
+ interface Thread { id: string; isResolved: boolean; isOutdated: boolean; path: string; line: number | null; comments: Page<Comment> }
66
+ function graphql<T>(query: string, variables: Record<string, string | number>): T {
67
+ const response = json<{ data?: T; errors?: unknown[] }>(["api", "graphql", "-f", `query=${query}`, ...Object.entries(variables).flatMap(([key, value]) => [typeof value === "number" ? "-F" : "-f", `${key}=${value}`])]);
68
+ assert(response.data && !response.errors?.length, "GitHub GraphQL response was incomplete.");
69
+ return response.data;
70
+ }
71
+ const commentFields = "nodes { id databaseId body url author { login } } pageInfo { hasNextPage endCursor }";
72
+ export function reviewThreads(pr: { prNumber: string; repo: string }): Thread[] {
73
+ const [owner, name] = pr.repo.split("/") as [string, string];
74
+ const threads: Thread[] = [];
75
+ let cursor: string | undefined;
76
+ do {
77
+ const data = graphql<{ repository: { pullRequest: { reviewThreads: Page<Thread> } } }>(`query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$cursor){nodes{id isResolved isOutdated path line comments(first:100){${commentFields}}} pageInfo{hasNextPage endCursor}}}}}`, { owner, name, number: Number(pr.prNumber), ...(cursor ? { cursor } : {}) });
78
+ const page = data.repository.pullRequest.reviewThreads;
79
+ assert(Array.isArray(page.nodes), "Invalid review thread page.");
80
+ threads.push(...page.nodes);
81
+ assert(!page.pageInfo.hasNextPage || !!page.pageInfo.endCursor && page.pageInfo.endCursor !== cursor, "Invalid thread pagination cursor.");
82
+ cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor! : undefined;
83
+ } while (cursor);
84
+ for (const thread of threads) {
85
+ let page = thread.comments;
86
+ const seen = new Set<string>();
87
+ while (page.pageInfo.hasNextPage) {
88
+ const after = page.pageInfo.endCursor;
89
+ assert(after && !seen.has(after), "Invalid comment pagination cursor."); seen.add(after);
90
+ const data = graphql<{ node: { comments: Page<Comment> } }>(`query($id:ID!,$cursor:String){node(id:$id){... on PullRequestReviewThread{comments(first:100,after:$cursor){${commentFields}}}}}`, { id: thread.id, cursor: after });
91
+ page = data.node.comments;
92
+ thread.comments.nodes.push(...page.nodes);
93
+ }
94
+ thread.comments.pageInfo = page.pageInfo;
95
+ }
96
+ return threads;
97
+ }
98
+
99
+ function threadsCommand(args: string[]): number {
100
+ const action = args.shift();
101
+ const identity = resolvePr();
102
+ if (action === "list") { assert(!args.length, "Usage: pr threads list"); output({ ...identity, threads: reviewThreads(identity) }); return 0; }
103
+ if (action === "reply") {
104
+ const bodyFile = option(args, "--body-file");
105
+ assert(args.length === 1 && /^\d+$/.test(args[0]!), "Usage: pr threads reply <comment-id> [--body-file <file>] (otherwise stdin)");
106
+ const body = readFileSync(bodyFile ?? 0, "utf8");
107
+ assert(body.trim(), "Reply body is empty.");
108
+ const comment = json<{ pull_request_url: string }>(["api", `repos/${identity.repo}/pulls/comments/${args[0]}`]);
109
+ assert(comment.pull_request_url.endsWith(`/repos/${identity.repo}/pulls/${identity.prNumber}`), "Comment belongs to another PR.");
110
+ output(JSON.parse(execFileSync("gh", ["api", "--method", "POST", `repos/${identity.repo}/pulls/${identity.prNumber}/comments/${args[0]}/replies`, "--input", "-"], { input: JSON.stringify({ body }), encoding: "utf8", env: gitEnvironment(), maxBuffer: 64 * 1024 * 1024 }))); return 0;
111
+ }
112
+ assert(action === "resolve" && args.length === 1, "Usage: pr threads resolve <thread-id>");
113
+ const thread = reviewThreads(identity).find(thread => thread.id === args[0]);
114
+ assert(thread, "Thread does not belong to this PR.");
115
+ if (thread.isResolved) { output({ id: thread.id, isResolved: true, changed: false }); return 0; }
116
+ output(graphql("mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{id isResolved}}}", { id: thread.id })); return 0;
117
+ }
118
+
119
+ export async function prWorkflowCommand(args: string[]): Promise<number> {
120
+ const action = args.shift();
121
+ if (action === "threads") return threadsCommand(args);
122
+ if (action === "context") {
123
+ assert(!args.length, "Usage: pr context");
124
+ const identity = resolvePr(); const pr = pullRequest(identity.prNumber, identity.repo);
125
+ output({ repo: identity.repo, ...pr, liveBaseOid: remoteBranchHead(resolveRepositoryGitUrl(identity.repo), pr.baseRefName), localHeadOid: head() }); return 0;
126
+ }
127
+ if (action === "preflight") { assert(args.length === 3, "Usage: pr preflight <reviewed-head> <base-branch> <reviewed-base>"); output(preflight(args[0]!, args[1]!, args[2]!)); return 0; }
128
+ assert(action === "checks", "Unknown PR workflow command.");
129
+ const wait = flag(args, "--wait");
130
+ const expected = option(args, "--head") ?? head();
131
+ const options = timeoutOptions(args);
132
+ assert(!args.length, "Usage: pr checks [--wait] [--head <oid>] [--timeout seconds] [--interval seconds]"); oid(expected);
133
+ const identity = resolvePr();
134
+ const inspect = () => { const value = checkSnapshot(identity, expected); return { done: !value.waiting, value }; };
135
+ const result = wait ? await poll(inspect, options) : inspect().value;
136
+ output({ ...identity, ...result }); return result.ok ? 0 : 1;
137
+ }
@@ -0,0 +1,109 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { loadConfig } from "../config";
5
+ import { remoteBranchHead, resolveFreshBaseRef, resolveRepositoryGitUrl } from "../git/prContext";
6
+ import { viewCurrentBranchPr } from "../git/currentBranchPr";
7
+ import { assert, clean, currentBranch, flag, git, head, mutateGit, oid, output, read, repository } from "./core";
8
+ import { pullRequest } from "./pr";
9
+ import { assertStrictBaseFreshness } from "../pr/baseFreshness";
10
+
11
+ export function repoCommand(args: string[]): number {
12
+ const action = args.shift();
13
+ if (action === "context") {
14
+ const fetch = flag(args, "--fetch");
15
+ assert(!args.length, "Usage: repo context [--fetch]");
16
+ const repo = repository();
17
+ const branch = git(["branch", "--show-current"]) || null;
18
+ const pr = branch ? viewCurrentBranchPr(branch) : undefined;
19
+ const baseRepo = pr?.repo ?? repo.nameWithOwner;
20
+ const baseBranch = pr?.baseRefName ?? repo.defaultBranchRef.name;
21
+ const fetchedBaseOid = fetch ? resolveFreshBaseRef(baseRepo, baseBranch) : undefined;
22
+ output({ repo: repo.nameWithOwner, defaultBranch: repo.defaultBranchRef.name, branch, headOid: head(), clean: !git(["status", "--porcelain=v1", "--untracked-files=all"]), pr: pr ?? null, baseRepo, baseBranch, liveBaseOid: remoteBranchHead(resolveRepositoryGitUrl(baseRepo), baseBranch), ...(fetch ? { fetchedBaseOid } : {}) }); return 0;
23
+ }
24
+ const apply = flag(args, "--apply");
25
+ const keep = flag(args, "--keep-branch");
26
+ assert(action === "cleanup" && args.length === 2 && /^\d+$/.test(args[0]!), "Usage: repo cleanup <PR-number> <shipped-head> [--apply] [--keep-branch]");
27
+ const shipped = oid(args[1]!);
28
+ const repo = repository();
29
+ const pr = pullRequest(args[0], repo.nameWithOwner);
30
+ assert(pr.state === "MERGED" && pr.mergeCommit?.oid && pr.headRefOid === shipped, "PR must be confirmed MERGED at the shipped HEAD.");
31
+ assert(!pr.isCrossRepository, "Cleanup of fork branches requires explicit manual checkout/remote handling.");
32
+ const defaultBranch = repo.defaultBranchRef.name;
33
+ assert(pr.headRefName !== defaultBranch, "Refusing to delete the default branch.");
34
+ clean();
35
+ const branch = currentBranch();
36
+ assert(branch === pr.headRefName || branch === defaultBranch, "Checkout is on an unrelated branch.");
37
+ if (branch === pr.headRefName) assert(head() === shipped, "Feature branch has commits beyond the shipped HEAD.");
38
+ const feature = spawnSync("git", ["rev-parse", "--verify", `refs/heads/${pr.headRefName}`], { encoding: "utf8" });
39
+ assert(feature.status === 0 || feature.status === 128, "Could not inspect feature branch.");
40
+ if (feature.status === 0) assert(feature.stdout.trim() === shipped, "Local feature branch no longer points to the shipped HEAD.");
41
+ const url = resolveRepositoryGitUrl(repo.nameWithOwner);
42
+ const remoteFeature = remoteBranchHead(url, pr.headRefName);
43
+ assert(!remoteFeature || remoteFeature === shipped, "Remote feature branch moved beyond the shipped HEAD.");
44
+ const plan = { repo: repo.nameWithOwner, pr: pr.number, mergeOid: pr.mergeCommit.oid, shippedHead: shipped, defaultBranch, featureBranch: pr.headRefName, keepBranch: keep };
45
+ if (!apply) { output({ ...plan, applied: false }); return 0; }
46
+ const completed: string[] = [];
47
+ try {
48
+ const defaultOid = resolveFreshBaseRef(repo.nameWithOwner, defaultBranch);
49
+ assert(git(["merge-base", defaultOid, pr.mergeCommit.oid]) === pr.mergeCommit.oid, "Default branch does not contain the confirmed merge commit.");
50
+ const localDefault = spawnSync("git", ["rev-parse", "--verify", `refs/heads/${defaultBranch}`], { encoding: "utf8" });
51
+ if (localDefault.status === 0) {
52
+ assert(git(["merge-base", localDefault.stdout.trim(), defaultOid]) === localDefault.stdout.trim(), "Local default branch diverged; preserve it for inspection.");
53
+ mutateGit(["switch", defaultBranch]);
54
+ mutateGit(["merge", "--ff-only", defaultOid]);
55
+ } else { assert(localDefault.status === 128, "Could not inspect local default branch."); mutateGit(["switch", "--no-track", "-c", defaultBranch, defaultOid]); }
56
+ assert(currentBranch() === defaultBranch && head() === defaultOid, "Checkout changed during default-branch update.");
57
+ clean(); completed.push("updated-default");
58
+ if (!keep) {
59
+ if (feature.status === 0) {
60
+ assert(git(["rev-parse", `refs/heads/${pr.headRefName}`]) === shipped, "Local feature branch changed before deletion.");
61
+ mutateGit(["branch", "-D", "--", pr.headRefName]); completed.push("deleted-local-branch");
62
+ }
63
+ const live = remoteBranchHead(url, pr.headRefName);
64
+ assert(!live || live === shipped, "Remote feature branch changed before deletion.");
65
+ if (live) {
66
+ // REST avoids pre-push hooks. GitHub has no expected-OID delete guard.
67
+ read("gh", ["api", "--method", "DELETE", `repos/${repo.nameWithOwner}/git/refs/heads/${encodeURIComponent(pr.headRefName)}`]);
68
+ completed.push("deleted-remote-branch");
69
+ }
70
+ }
71
+ output({ ...plan, applied: true, completed, branch: currentBranch(), headOid: head() }); return 0;
72
+ } catch (error) { output({ ...plan, applied: true, completed, error: (error as Error).message }); return 1; }
73
+ }
74
+
75
+ export function policyCommand(args: string[]): number {
76
+ assert(args.length === 1 && args[0] === "check", "Usage: policy check");
77
+ const repo = repository();
78
+ const branch = repo.defaultBranchRef.name;
79
+ const rules = read("gh", ["api", "--paginate", `repos/${repo.nameWithOwner}/rules/branches/${encodeURIComponent(branch)}`, "--jq", ".[]"]).split("\n").filter(Boolean).map((line): unknown => JSON.parse(line));
80
+ const protection = spawnSync("gh", ["api", `repos/${repo.nameWithOwner}/branches/${encodeURIComponent(branch)}/protection`], { encoding: "utf8" });
81
+ assert(protection.status === 0 || /HTTP 404/.test(protection.stderr), "Could not read classic branch protection.");
82
+ const classic = protection.status === 0 ? JSON.parse(protection.stdout) as { required_status_checks?: { contexts?: string[]; checks?: { context: string }[]; strict?: boolean } } : null;
83
+ const required = new Set<string>(classic?.required_status_checks?.contexts ?? []);
84
+ for (const check of classic?.required_status_checks?.checks ?? []) required.add(check.context);
85
+ for (const rule of rules as { type: string; parameters?: { required_status_checks?: { context: string }[] } }[]) if (rule.type === "required_status_checks") for (const check of rule.parameters?.required_status_checks ?? []) required.add(check.context);
86
+ const configured = loadConfig().merge;
87
+ const missing = [...required].filter(name => !configured.requiredChecks.some(check => check.name === name));
88
+ const issues = missing.map(name => `Required context '${name}' is absent from merge.requiredChecks.`);
89
+ if (configured.requireStrictBaseFreshness) { try { assertStrictBaseFreshness(repo.nameWithOwner, branch); } catch (error) { issues.push((error as Error).message); } }
90
+ output({ ok: !issues.length, repo: repo.nameWithOwner, branch, configured, classicProtection: classic, effectiveRules: rules, requiredContexts: [...required], missingConfiguredChecks: missing, issues, notes: ["Workflow names and conditional aggregate dependencies require workflow inspection; protection contexts alone do not identify workflows.", "A classic-protection 404 means unavailable or absent; effective rules are queried independently."] });
91
+ return issues.length ? 1 : 0;
92
+ }
93
+
94
+ export function toolchainCommand(args: string[]): number {
95
+ assert(args.length === 1 && args[0] === "check", "Usage: toolchain check");
96
+ const file = path.join(process.cwd(), ".mise.toml");
97
+ assert(existsSync(file), "No .mise.toml in this directory.");
98
+ const config = Bun.TOML.parse(readFileSync(file, "utf8")) as { tools?: Record<string, unknown> };
99
+ const tools = Object.entries(config.tools ?? {}).map(([name, pin]) => {
100
+ const expected = typeof pin === "string" ? pin : null;
101
+ const executable = name === "python" ? "python3" : name;
102
+ const supported = /^[a-z][a-z0-9-]*$/.test(executable) && expected !== null && /^\d+(?:\.\d+){1,3}$/.test(expected);
103
+ const resolved = supported ? Bun.which(executable) : null;
104
+ const version = resolved ? spawnSync(resolved, ["--version"], { encoding: "utf8", timeout: 5000, maxBuffer: 65536 }) : null;
105
+ const actual = `${version?.stdout ?? ""}\n${version?.stderr ?? ""}`.match(/\b\d+\.\d+(?:\.\d+){0,2}\b/)?.[0] ?? null;
106
+ return { name, expected, resolved, actual, ok: supported && version?.status === 0 && (actual === expected || actual?.startsWith(`${expected}.`) === true), ...(supported ? {} : { reason: "Unsupported pin syntax; inspect manually." }) };
107
+ });
108
+ output({ ok: tools.length > 0 && tools.every(tool => tool.ok), file, tools }); return tools.length && tools.every(tool => tool.ok) ? 0 : 1;
109
+ }