diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 7a302c2879..37c5af7136 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { runAttempt } from "../lib/attempt-cli.js"; import { printHelp, printVersion, runCli } from "../lib/cli.js"; import { runDenyCheck } from "../lib/deny-check.js"; import { runDiscover } from "../lib/discover-cli.js"; @@ -121,6 +122,12 @@ if (cliArgs[0] === "discover") { process.exit(exitCode); } +if (cliArgs[0] === "attempt") { + const exitCode = await runAttempt(cliArgs.slice(1)); + await awaitOpportunisticUpdateCheck(updateCheck); + process.exit(exitCode); +} + const exitCode = runCli(cliArgs, { packageName }); await awaitOpportunisticUpdateCheck(updateCheck); process.exit(exitCode); diff --git a/packages/gittensory-miner/lib/attempt-cli.d.ts b/packages/gittensory-miner/lib/attempt-cli.d.ts new file mode 100644 index 0000000000..5baf2e3005 --- /dev/null +++ b/packages/gittensory-miner/lib/attempt-cli.d.ts @@ -0,0 +1,33 @@ +import type { CodingAgentExecutionMode } from "@jsonbored/gittensory-engine"; +import type { AttemptDeps } from "./attempt-runner.js"; +import type { ClaimLedger } from "./claim-ledger.js"; +import type { EventLedger } from "./event-ledger.js"; +import type { AttemptLog } from "./attempt-log.js"; +import type { GovernorLedger } from "./governor-ledger.js"; +import type { WorktreeAllocator } from "./worktree-allocator.js"; + +export type ParsedAttemptArgs = + | { error: string } + | { repoFullName: string; issueNumber: number; minerLogin: string; base: string; live: boolean; json: boolean }; + +export function parseAttemptArgs(args: string[]): ParsedAttemptArgs; + +export function buildAttemptDeps( + env: Record, + ledgers: { claimLedger: ClaimLedger; eventLedger: EventLedger; attemptLog: AttemptLog; governorLedger: GovernorLedger; nowMs: number }, +): AttemptDeps; + +export type RunAttemptOptions = { + env?: Record; + nowMs?: number; + attemptId?: string; + resolveCodingAgentModeFromConfig?: (config: { env?: Record }) => CodingAgentExecutionMode; + openWorktreeAllocator?: () => WorktreeAllocator; + openClaimLedger?: () => ClaimLedger; + initEventLedger?: () => EventLedger; + initAttemptLog?: () => AttemptLog; + initGovernorLedger?: () => GovernorLedger; + buildAttemptDeps?: typeof buildAttemptDeps; +}; + +export function runAttempt(args: string[], options?: RunAttemptOptions): Promise; diff --git a/packages/gittensory-miner/lib/attempt-cli.js b/packages/gittensory-miner/lib/attempt-cli.js new file mode 100644 index 0000000000..a4b116a38e --- /dev/null +++ b/packages/gittensory-miner/lib/attempt-cli.js @@ -0,0 +1,221 @@ +// CLI dispatch for the real attempt pipeline (#5132, Wave 3.5). Wires bin/gittensory-miner.js's `attempt` +// subcommand to real infrastructure: worktree allocation (worktree-allocator.js's first real, non-test +// caller), the four ledgers (claim/event/attempt-log/governor), the real coding-agent driver (#5131) and +// slop assessor (#5133), the fetchLiveIssueSnapshot/executeLocalWrite built alongside this file, and mode +// resolution. +// +// KNOWN, DELIBERATE GAP: runMinerAttempt requires `loopInput.reviewContext: SelfReviewContext` (issue/PR/ +// manifest data at live-gate fidelity, tracked by #5145) AND a full coding-task spec (title/instructions/ +// acceptanceCriteriaPath, derived from the target issue -- no builder for that exists anywhere in this +// package either, a second gap discovered while building this file and noted on #5132). Rather than +// fabricate placeholder data for either -- which would let a self-review pass "look real" while checking +// nothing -- this command builds and verifies every OTHER real dependency, then reports the block clearly +// instead of calling runMinerAttempt with an invalid or fabricated input. + +import { resolveCodingAgentModeFromConfig } from "@jsonbored/gittensory-engine"; +import { constructProductionCodingAgentDriver } from "./coding-agent-construction.js"; +import { runSlopAssessment } from "./slop-assessment.js"; +import { fetchLiveIssueSnapshot } from "./live-issue-snapshot.js"; +import { executeLocalWrite } from "./execute-local-write.js"; +import { openClaimLedger } from "./claim-ledger.js"; +import { initEventLedger } from "./event-ledger.js"; +import { initAttemptLog } from "./attempt-log.js"; +import { initGovernorLedger } from "./governor-ledger.js"; +import { openWorktreeAllocator } from "./worktree-allocator.js"; + +const ATTEMPT_USAGE = "Usage: gittensory-miner attempt --miner-login [--base ] [--live] [--json]"; + +function parseRepoTarget(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) return null; + return `${owner}/${repo}`; +} + +export function parseAttemptArgs(args) { + const options = { json: false, minerLogin: null, base: "main", live: false }; + const positional = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + // Opt-in only: resolveCodingAgentModeFromConfig's own default (no agentDryRun override) is "live", not + // "dry_run" -- so #5132's "dry-run is default" acceptance criteria (#2342) has to be enforced HERE, by + // requiring an explicit --live flag before this command will ever request live mode. + if (token === "--live") { + options.live = true; + continue; + } + if (token === "--miner-login") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE }; + options.minerLogin = value; + index += 1; + continue; + } + if (token === "--base") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE }; + options.base = value; + index += 1; + continue; + } + if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; + positional.push(token); + } + + if (positional.length !== 2) return { error: ATTEMPT_USAGE }; + const repoFullName = parseRepoTarget(positional[0]); + if (!repoFullName) return { error: `Repository must be in owner/repo form: ${positional[0]}` }; + const issueNumber = Number(positional[1]); + if (!Number.isInteger(issueNumber) || issueNumber < 1) { + return { error: `Issue number must be a positive integer: ${positional[1]}` }; + } + if (!options.minerLogin) return { error: `--miner-login is required. ${ATTEMPT_USAGE}` }; + + return { + repoFullName, + issueNumber, + minerLogin: options.minerLogin, + base: options.base, + live: options.live, + json: options.json, + }; +} + +/** + * Assemble a real AttemptDeps object: every field wired to a genuine implementation (the #5131 driver, the + * #5133 slop assessor, the four real ledgers passed in, and the fetchLiveIssueSnapshot/executeLocalWrite + * built alongside this file). Throws if the coding-agent driver is unconfigured (fails closed, matching + * constructProductionCodingAgentDriver's own contract) -- callers should report that clearly rather than + * silently falling back to a driver that could never run. + * + * @param {Record} env + * @param {{ + * claimLedger: import("./claim-ledger.js").ClaimLedger, + * eventLedger: import("./event-ledger.js").EventLedger, + * attemptLog: import("./attempt-log.js").AttemptLog, + * governorLedger: import("./governor-ledger.js").GovernorLedger, + * nowMs: number, + * }} ledgers + * @returns {import("./attempt-runner.js").AttemptDeps} + */ +export function buildAttemptDeps(env, ledgers) { + return { + driver: constructProductionCodingAgentDriver(env), + runSlopAssessment: (input) => runSlopAssessment(input), + appendAttemptLogEvent: (event) => ledgers.attemptLog.appendAttemptLogEvent(event), + claimLedger: ledgers.claimLedger, + fetchLiveIssueSnapshot: (repoFullName, issueNumber) => fetchLiveIssueSnapshot(repoFullName, issueNumber, { githubToken: env.GITHUB_TOKEN }), + eventLedger: ledgers.eventLedger, + governorLedgerAppend: (event) => ledgers.governorLedger.appendGovernorEvent(event), + nowMs: ledgers.nowMs, + executeLocalWrite: (spec) => executeLocalWrite(spec), + }; +} + +/** + * Run the `attempt` CLI subcommand. Acquires a real worktree slot (worktree-allocator.js's first + * production caller), assembles real AttemptDeps, then -- since no SelfReviewContext fetcher or + * coding-task-spec builder exists yet -- reports the block instead of calling runMinerAttempt with + * fabricated data. See this file's header for why. + */ +export async function runAttempt(args, options = {}) { + const parsed = parseAttemptArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + const env = options.env ?? process.env; + const nowMs = options.nowMs ?? Date.now(); + const resolveMode = options.resolveCodingAgentModeFromConfig ?? resolveCodingAgentModeFromConfig; + const mode = resolveMode({ env, agentDryRun: !parsed.live }); + + if (mode === "paused") { + console.error( + `Coding-agent execution is globally paused (MINER_CODING_AGENT_PAUSED). Not running attempt for ${parsed.repoFullName}#${parsed.issueNumber}.`, + ); + return 3; + } + + const attemptId = options.attemptId ?? `${parsed.repoFullName.replace("/", "_")}-${parsed.issueNumber}-${nowMs}`; + + let allocator = null; + let claimLedger = null; + let eventLedger = null; + let attemptLog = null; + let governorLedger = null; + let allocation = null; + + try { + allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)(); + claimLedger = (options.openClaimLedger ?? openClaimLedger)(); + eventLedger = (options.initEventLedger ?? initEventLedger)(); + attemptLog = (options.initAttemptLog ?? initAttemptLog)(); + governorLedger = (options.initGovernorLedger ?? initGovernorLedger)(); + + allocation = allocator.acquire(attemptId, parsed.repoFullName); + + try { + const buildDeps = options.buildAttemptDeps ?? buildAttemptDeps; + buildDeps(env, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: ${reason}`); + return 3; + } + + const reason = "missing_self_review_context_and_task_spec"; + const blockedResult = { + outcome: "blocked_missing_prerequisite", + reason, + trackingIssue: 5145, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + worktreePath: allocation.worktreePath, + }; + + // "attempt_aborted" is the closest fit in ATTEMPT_LOG_EVENT_TYPES's fixed vocabulary + // (@jsonbored/gittensory-engine) for "never started because a hard prerequisite is missing". + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, trackingIssue: 5145 }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason, trackingIssue: 5145 }, + }); + + if (parsed.json) { + console.log(JSON.stringify(blockedResult, null, 2)); + } else { + console.log( + `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: no SelfReviewContext fetcher or coding-task-spec builder yet (tracked by #5145). Worktree, ledgers, driver, live-issue fetch, and local-write execution are wired and ready; runMinerAttempt was not invoked.`, + ); + } + return 4; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } finally { + if (allocation && allocator) allocator.release(attemptId); + allocator?.close(); + claimLedger?.close(); + eventLedger?.close(); + attemptLog?.close(); + governorLedger?.close(); + } +} diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index eedb62fd06..e97278f51a 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -21,6 +21,7 @@ export function printHelp(input) { " gittensory-miner manage poll [--branch ] [--json]", " gittensory-miner discover [...] [--json]", " gittensory-miner discover --search [--json] Fan out, rank, and enqueue candidates", + " gittensory-miner attempt --miner-login [--base ] [--live] [--json]", " gittensory-miner queue list [--repo ] [--json] List portfolio backlog rows", " gittensory-miner queue next [--json] Claim the highest-priority queued item", " gittensory-miner queue done [--json]", diff --git a/packages/gittensory-miner/lib/execute-local-write.d.ts b/packages/gittensory-miner/lib/execute-local-write.d.ts new file mode 100644 index 0000000000..e5a108b051 --- /dev/null +++ b/packages/gittensory-miner/lib/execute-local-write.d.ts @@ -0,0 +1,14 @@ +import type { LocalWriteActionSpec } from "@jsonbored/gittensory-engine"; + +export type ExecuteLocalWriteResult = { + action: string; + stdout: string; + stderr: string; + code: number | null; + timedOut: boolean; +}; + +export function executeLocalWrite( + spec: LocalWriteActionSpec, + options?: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }, +): Promise; diff --git a/packages/gittensory-miner/lib/execute-local-write.js b/packages/gittensory-miner/lib/execute-local-write.js new file mode 100644 index 0000000000..13d6cc3cfc --- /dev/null +++ b/packages/gittensory-miner/lib/execute-local-write.js @@ -0,0 +1,50 @@ +// Real executeLocalWrite implementation (#5132, Wave 3.5). Mirrors coding-agent-construction.js's +// createRealCliSubprocessSpawn pattern (real child_process, resolve-not-reject on error/timeout so a +// killed/errored process's partial output -- e.g. an auth failure line on stderr -- is never lost to an +// unhandled rejection) but for LocalWriteActionSpec.command: a single shell-safe string (built with +// packages/gittensory-engine/src/miner/local-write-tools.ts's own single-quote escaping), not the +// cmd/args-array CliSubprocessSpawnFn contract the coding-agent driver itself uses. Runs it via `sh -c` in +// the given working directory. Per local-write-tools.ts's own boundary comment, this always runs with +// whatever `gh`/`git` credentials are already configured in that environment -- gittensory never performs +// the write itself. + +import { spawn } from "node:child_process"; + +const DEFAULT_TIMEOUT_MS = 120_000; + +/** + * @param {import("@jsonbored/gittensory-engine").LocalWriteActionSpec} spec + * @param {{ cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs?: number }} [options] + * @returns {Promise<{ action: string, stdout: string, stderr: string, code: number | null, timedOut: boolean }>} + */ +export function executeLocalWrite(spec, options = {}) { + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS; + + return new Promise((resolve) => { + const child = spawn("sh", ["-c", spec.command], { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve({ action: spec.action, stdout, stderr, code: null, timedOut: true }); + }, timeoutMs); + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (err) => { + // A spawn-level error (e.g. no `sh` on PATH) fires before the child ever produces output -- mirrors + // createRealCliSubprocessSpawn's own identical handling. + clearTimeout(timer); + resolve({ action: spec.action, stdout, stderr: err.message, code: null, timedOut: false }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ action: spec.action, stdout, stderr, code, timedOut: false }); + }); + }); +} diff --git a/packages/gittensory-miner/lib/live-issue-snapshot.d.ts b/packages/gittensory-miner/lib/live-issue-snapshot.d.ts new file mode 100644 index 0000000000..6da319fbea --- /dev/null +++ b/packages/gittensory-miner/lib/live-issue-snapshot.d.ts @@ -0,0 +1,16 @@ +import type { LiveIssueSnapshot } from "./submission-freshness-check.js"; + +// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a +// plain POST init, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored +// (RequestInfo | URL), which is both irrelevant here (this package runs under plain Node) and +// stricter than any real caller needs. +export type LiveIssueSnapshotFetch = ( + url: string, + init: { method: string; headers: Record; body: string }, +) => Promise<{ ok: boolean; status: number; json: () => Promise }>; + +export function fetchLiveIssueSnapshot( + repoFullName: string, + issueNumber: number, + options?: { githubToken?: string; graphqlUrl?: string; fetchImpl?: LiveIssueSnapshotFetch }, +): Promise; diff --git a/packages/gittensory-miner/lib/live-issue-snapshot.js b/packages/gittensory-miner/lib/live-issue-snapshot.js new file mode 100644 index 0000000000..bea4410071 --- /dev/null +++ b/packages/gittensory-miner/lib/live-issue-snapshot.js @@ -0,0 +1,109 @@ +// Real GitHub-backed fetchLiveIssueSnapshot (#5132, Wave 3.5). AttemptDeps.fetchLiveIssueSnapshot and +// SubmissionFreshnessDeps.fetchLiveIssueSnapshot (submission-freshness-check.js) share this one shape: +// "is this issue still open, and is it already addressed by another PR" -- the live-state answer +// checkSubmissionFreshness needs before every submission. Uses GitHub's GraphQL +// `closedByPullRequestsReferences` connection rather than a body-text/search-API heuristic: it's GitHub's +// own authoritative, closing-keyword-aware answer to "which PRs will close this issue" -- the same signal +// the platform itself uses to auto-close on merge, not a regex we'd have to keep in sync with GitHub's own +// closing-keyword parsing. + +const DEFAULT_GRAPHQL_URL = "https://api.github.com/graphql"; +const GITHUB_API_VERSION = "2022-11-28"; +const MAX_REFERENCING_PRS = 50; + +const LIVE_ISSUE_SNAPSHOT_QUERY = ` + query($owner: String!, $repo: String!, $number: Int!, $maxPrs: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + state + closedByPullRequestsReferences(first: $maxPrs) { + nodes { + number + state + author { login } + } + } + } + } + } +`; + +function githubGraphqlHeaders(githubToken) { + const headers = { + accept: "application/vnd.github+json", + "content-type": "application/json", + "user-agent": "gittensory-miner", + "x-github-api-version": GITHUB_API_VERSION, + }; + const token = typeof githubToken === "string" ? githubToken.trim() : ""; + if (token) headers.authorization = `Bearer ${token}`; + return headers; +} + +function normalizeIssueOrPrState(rawState) { + return typeof rawState === "string" ? rawState.toLowerCase() : ""; +} + +function normalizeReferencingPr(node) { + if (!node || typeof node !== "object") return null; + if (!Number.isInteger(node.number) || node.number <= 0) return null; + const state = normalizeIssueOrPrState(node.state); + if (state !== "open" && state !== "closed" && state !== "merged") return null; + const authorLogin = typeof node.author?.login === "string" ? node.author.login : ""; + return { number: node.number, state, authorLogin }; +} + +function parseRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) return null; + return { owner, repo }; +} + +/** + * Real fetchLiveIssueSnapshot implementation: the live-state answer AttemptDeps/SubmissionFreshnessDeps + * need, built from a single GraphQL round-trip. Returns null on any malformed input, transport failure, or + * unrecognized GitHub response -- callers already treat a null snapshot as "state unavailable", so this + * never throws. + * + * @param {string} repoFullName + * @param {number} issueNumber + * @param {{ githubToken?: string, graphqlUrl?: string, fetchImpl?: typeof fetch }} [options] + * @returns {Promise} + */ +export async function fetchLiveIssueSnapshot(repoFullName, issueNumber, options = {}) { + const target = parseRepoFullName(repoFullName); + if (!target || !Number.isInteger(issueNumber) || issueNumber <= 0) return null; + + const graphqlUrl = + typeof options.graphqlUrl === "string" && options.graphqlUrl.trim() ? options.graphqlUrl.trim() : DEFAULT_GRAPHQL_URL; + const githubToken = options.githubToken ?? process.env.GITHUB_TOKEN ?? ""; + const fetchImpl = options.fetchImpl ?? fetch; + + let response; + try { + response = await fetchImpl(graphqlUrl, { + method: "POST", + headers: githubGraphqlHeaders(githubToken), + body: JSON.stringify({ + query: LIVE_ISSUE_SNAPSHOT_QUERY, + variables: { owner: target.owner, repo: target.repo, number: issueNumber, maxPrs: MAX_REFERENCING_PRS }, + }), + }); + } catch { + return null; + } + if (!response.ok) return null; + + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== "object" || payload.errors) return null; + + const issue = payload.data?.repository?.issue; + const state = normalizeIssueOrPrState(issue?.state); + if (state !== "open" && state !== "closed") return null; + + const nodes = Array.isArray(issue?.closedByPullRequestsReferences?.nodes) ? issue.closedByPullRequestsReferences.nodes : []; + const referencingPrs = nodes.map(normalizeReferencingPr).filter((pr) => pr !== null); + + return { state, referencingPrs }; +} diff --git a/test/unit/miner-attempt-cli.test.ts b/test/unit/miner-attempt-cli.test.ts new file mode 100644 index 0000000000..bd2a33f18b --- /dev/null +++ b/test/unit/miner-attempt-cli.test.ts @@ -0,0 +1,319 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { closeDefaultClaimLedger, openClaimLedger } from "../../packages/gittensory-miner/lib/claim-ledger.js"; +import { closeDefaultEventLedger, initEventLedger } from "../../packages/gittensory-miner/lib/event-ledger.js"; +import { closeDefaultAttemptLog, initAttemptLog } from "../../packages/gittensory-miner/lib/attempt-log.js"; +import { closeDefaultGovernorLedger, initGovernorLedger } from "../../packages/gittensory-miner/lib/governor-ledger.js"; +import { closeDefaultWorktreeAllocator, openWorktreeAllocator } from "../../packages/gittensory-miner/lib/worktree-allocator.js"; +import { buildAttemptDeps, parseAttemptArgs, runAttempt } from "../../packages/gittensory-miner/lib/attempt-cli.js"; + +const roots: string[] = []; +// Only ever holds ledgers a test itself must close -- runAttempt tests inject theirs via DI and runAttempt's +// own `finally` block closes them, so registering the same objects here would double-close (the underlying +// SQLite handle throws "database is not open" / "statement has been finalized" on a second close()). +const closeables: Array<{ close(): void }> = []; + +function tempLedgers() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-attempt-cli-")); + roots.push(root); + const allocator = openWorktreeAllocator({ + dbPath: join(root, "worktree-allocator.sqlite3"), + worktreeBaseDir: join(root, "worktrees"), + }); + const claimLedger = openClaimLedger(join(root, "claim-ledger.sqlite3")); + const eventLedger = initEventLedger(join(root, "event-ledger.sqlite3")); + const attemptLog = initAttemptLog(join(root, "attempt-log.sqlite3")); + const governorLedger = initGovernorLedger(join(root, "governor-ledger.sqlite3")); + return { allocator, claimLedger, eventLedger, attemptLog, governorLedger }; +} + +afterEach(() => { + for (const closeable of closeables.splice(0)) closeable.close(); + closeDefaultWorktreeAllocator(); + closeDefaultClaimLedger(); + closeDefaultEventLedger(); + closeDefaultAttemptLog(); + closeDefaultGovernorLedger(); + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("parseAttemptArgs (#5132)", () => { + it("parses a full, valid argv", () => { + expect(parseAttemptArgs(["acme/widgets", "7", "--miner-login", "alice", "--base", "develop", "--live", "--json"])).toEqual({ + repoFullName: "acme/widgets", + issueNumber: 7, + minerLogin: "alice", + base: "develop", + live: true, + json: true, + }); + }); + + it("defaults base to main, live to false, and json to false", () => { + expect(parseAttemptArgs(["acme/widgets", "7", "--miner-login", "alice"])).toEqual({ + repoFullName: "acme/widgets", + issueNumber: 7, + minerLogin: "alice", + base: "main", + live: false, + json: false, + }); + }); + + it("requires exactly repo and issue number as positional args", () => { + expect(parseAttemptArgs([])).toEqual({ error: expect.stringContaining("Usage: gittensory-miner attempt") }); + expect(parseAttemptArgs(["acme/widgets"])).toEqual({ error: expect.stringContaining("Usage:") }); + expect(parseAttemptArgs(["acme/widgets", "7", "extra", "--miner-login", "alice"])).toEqual({ + error: expect.stringContaining("Usage:"), + }); + }); + + it("rejects a malformed repo target", () => { + expect(parseAttemptArgs(["not-a-repo", "7", "--miner-login", "alice"])).toEqual({ + error: "Repository must be in owner/repo form: not-a-repo", + }); + }); + + it("rejects a non-positive or non-integer issue number", () => { + expect(parseAttemptArgs(["acme/widgets", "0", "--miner-login", "alice"])).toEqual({ + error: "Issue number must be a positive integer: 0", + }); + expect(parseAttemptArgs(["acme/widgets", "abc", "--miner-login", "alice"])).toEqual({ + error: "Issue number must be a positive integer: abc", + }); + }); + + it("requires --miner-login", () => { + expect(parseAttemptArgs(["acme/widgets", "7"])).toEqual({ + error: expect.stringContaining("--miner-login is required"), + }); + }); + + it("rejects --miner-login or --base with a missing or flag-like value", () => { + expect(parseAttemptArgs(["acme/widgets", "7", "--miner-login"])).toEqual({ + error: expect.stringContaining("Usage:"), + }); + expect(parseAttemptArgs(["acme/widgets", "7", "--base", "--json"])).toEqual({ + error: expect.stringContaining("Usage:"), + }); + }); + + it("rejects unknown options", () => { + expect(parseAttemptArgs(["acme/widgets", "7", "--miner-login", "alice", "--verbose"])).toEqual({ + error: "Unknown option: --verbose", + }); + }); +}); + +describe("buildAttemptDeps (#5132)", () => { + it("assembles a fully real AttemptDeps object when a coding-agent provider is configured", () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + closeables.push(allocator, claimLedger, eventLedger, attemptLog, governorLedger); + const deps = buildAttemptDeps({ MINER_CODING_AGENT_PROVIDER: "noop" }, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs: 12345 }); + + expect(typeof deps.driver.run).toBe("function"); + expect(typeof deps.runSlopAssessment).toBe("function"); + expect(typeof deps.appendAttemptLogEvent).toBe("function"); + expect(deps.claimLedger).toBe(claimLedger); + expect(typeof deps.fetchLiveIssueSnapshot).toBe("function"); + expect(deps.eventLedger).toBe(eventLedger); + expect(typeof deps.governorLedgerAppend).toBe("function"); + expect(deps.nowMs).toBe(12345); + expect(typeof deps.executeLocalWrite).toBe("function"); + }); + + it("wires appendAttemptLogEvent and governorLedgerAppend through to the real ledgers", () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + closeables.push(allocator, claimLedger, eventLedger, attemptLog, governorLedger); + const deps = buildAttemptDeps({ MINER_CODING_AGENT_PROVIDER: "noop" }, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs: 1 }); + + deps.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId: "a1", + actionClass: "open_pr", + mode: "dry_run", + reason: "test", + payload: {}, + }); + expect(attemptLog.readAttemptLogEvents({ attemptId: "a1" })).toHaveLength(1); + + deps.governorLedgerAppend?.({ + eventType: "allowed", + repoFullName: "acme/widgets", + actionClass: "open_pr", + decision: "allow", + reason: "test", + }); + expect(governorLedger.readGovernorEvents({})).toHaveLength(1); + }); + + it("fails closed (throws) when no coding-agent provider is configured", () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + closeables.push(allocator, claimLedger, eventLedger, attemptLog, governorLedger); + expect(() => buildAttemptDeps({}, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs: 1 })).toThrow( + /unconfigured_coding_agent_driver/, + ); + }); +}); + +describe("runAttempt (#5132)", () => { + it("short-circuits with a usage error on malformed args, before touching any ledger or allocator", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const openWorktreeAllocatorSpy = vi.fn(); + const exitCode = await runAttempt([], { openWorktreeAllocator: openWorktreeAllocatorSpy }); + expect(exitCode).toBe(2); + expect(error).toHaveBeenCalledWith(expect.stringContaining("Usage: gittensory-miner attempt")); + expect(openWorktreeAllocatorSpy).not.toHaveBeenCalled(); + }); + + it("short-circuits when coding-agent execution is globally paused, before touching any ledger or allocator", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const openWorktreeAllocatorSpy = vi.fn(); + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice"], { + env: { MINER_CODING_AGENT_PAUSED: "1" }, + openWorktreeAllocator: openWorktreeAllocatorSpy, + }); + expect(exitCode).toBe(3); + expect(error).toHaveBeenCalledWith(expect.stringContaining("globally paused")); + expect(openWorktreeAllocatorSpy).not.toHaveBeenCalled(); + }); + + it("acquires and releases a real worktree slot, wires real deps, then reports the block instead of fabricating a run", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + // runAttempt closes every ledger/allocator it owns in its own `finally` block (correct for a real CLI + // invocation), so post-invocation state can't be read off these same instances -- spy on the calls + // instead, asserted before close() ever fires. + const releaseSpy = vi.spyOn(allocator, "release"); + const appendAttemptLogEventSpy = vi.spyOn(attemptLog, "appendAttemptLogEvent"); + const appendEventSpy = vi.spyOn(eventLedger, "appendEvent"); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + nowMs: 999, + attemptId: "fixed-attempt-id", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + }); + + expect(exitCode).toBe(4); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed).toEqual({ + outcome: "blocked_missing_prerequisite", + reason: "missing_self_review_context_and_task_spec", + trackingIssue: 5145, + repoFullName: "acme/widgets", + issueNumber: 7, + minerLogin: "alice", + base: "main", + mode: "dry_run", + attemptId: "fixed-attempt-id", + worktreePath: expect.any(String), + }); + + // The worktree slot was acquired for real and then released, not left dangling. + expect(releaseSpy).toHaveBeenCalledWith("fixed-attempt-id"); + // A real, persisted record of the block was written to both ledgers -- not just console output. + expect(appendAttemptLogEventSpy).toHaveBeenCalledWith(expect.objectContaining({ eventType: "attempt_aborted", attemptId: "fixed-attempt-id" })); + expect(appendEventSpy).toHaveBeenCalledWith(expect.objectContaining({ type: "attempt_blocked", repoFullName: "acme/widgets" })); + }); + + it("resolves live mode only when --live is passed", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--live", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + }); + + expect(exitCode).toBe(4); + expect(JSON.parse(String(log.mock.calls[0]?.[0])).mode).toBe("live"); + }); + + it("prints a human-readable message (not JSON) by default", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + }); + + expect(exitCode).toBe(4); + expect(String(log.mock.calls[0]?.[0])).toContain("is blocked"); + expect(String(log.mock.calls[0]?.[0])).toContain("#5145"); + }); + + it("reports and cleans up when the coding-agent driver is unconfigured, still releasing the worktree slot", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const releaseSpy = vi.spyOn(allocator, "release"); + const appendAttemptLogEventSpy = vi.spyOn(attemptLog, "appendAttemptLogEvent"); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice"], { + env: {}, + attemptId: "unconfigured-attempt", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + }); + + expect(exitCode).toBe(3); + expect(error).toHaveBeenCalledWith(expect.stringContaining("unconfigured_coding_agent_driver")); + expect(releaseSpy).toHaveBeenCalledWith("unconfigured-attempt"); + // The block was never logged to the ledgers -- the driver-construction failure short-circuits before that. + expect(appendAttemptLogEventSpy).not.toHaveBeenCalled(); + }); + + it("reports an unexpected allocator failure and still closes every already-open ledger", async () => { + const { claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const closeSpy = vi.spyOn(claimLedger, "close"); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => ({ + dbPath: ":memory:", + worktreeBaseDir: "/tmp/unused", + maxConcurrency: 1, + processPid: process.pid, + acquire: () => { + throw new Error("no_free_worktree_slots"); + }, + release: vi.fn(), + listSlots: () => [], + close: vi.fn(), + }), + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + }); + + expect(exitCode).toBe(2); + expect(error).toHaveBeenCalledWith(expect.stringContaining("no_free_worktree_slots")); + expect(closeSpy).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/miner-execute-local-write.test.ts b/test/unit/miner-execute-local-write.test.ts new file mode 100644 index 0000000000..9867d570cf --- /dev/null +++ b/test/unit/miner-execute-local-write.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { executeLocalWrite } from "../../packages/gittensory-miner/lib/execute-local-write.js"; + +function spec(command: string, action = "open_pr") { + return { action, description: "test spec", inputs: {}, command, boundary: "boundary text" }; +} + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("executeLocalWrite (#5132)", () => { + it("captures stdout and a zero exit code from a real short-lived command", async () => { + const result = await executeLocalWrite(spec("printf hello")); + expect(result).toEqual({ action: "open_pr", stdout: "hello", stderr: "", code: 0, timedOut: false }); + }); + + it("captures stderr and a non-zero exit code", async () => { + const result = await executeLocalWrite(spec("echo oops 1>&2; exit 2")); + expect(result.code).toBe(2); + expect(result.stderr).toBe("oops\n"); + expect(result.timedOut).toBe(false); + }); + + it("resolves (never rejects) with code:null when the shell itself cannot be spawned", async () => { + const result = await executeLocalWrite(spec("echo hi"), { cwd: "/definitely/does/not/exist/xyz" }); + expect(result.code).toBeNull(); + expect(result.timedOut).toBe(false); + expect(result.stderr.length).toBeGreaterThan(0); + }); + + it("kills a long-lived command and resolves with timedOut:true when the timeout elapses", async () => { + const result = await executeLocalWrite(spec("sleep 5"), { timeoutMs: 100 }); + expect(result.code).toBeNull(); + expect(result.timedOut).toBe(true); + }); + + it("runs in the given working directory and inherits the given env", async () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-execute-local-write-")); + roots.push(root); + const resolvedRoot = realpathSync(root); + const result = await executeLocalWrite(spec("pwd"), { cwd: resolvedRoot, env: { ...process.env, PATH: process.env.PATH ?? "" } }); + expect(result.stdout.trim()).toBe(resolvedRoot); + expect(result.code).toBe(0); + }); + + it("preserves the spec's action in the result", async () => { + const result = await executeLocalWrite(spec("true", "file_issue")); + expect(result.action).toBe("file_issue"); + }); +}); diff --git a/test/unit/miner-live-issue-snapshot.test.ts b/test/unit/miner-live-issue-snapshot.test.ts new file mode 100644 index 0000000000..3ea7a38c6f --- /dev/null +++ b/test/unit/miner-live-issue-snapshot.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { fetchLiveIssueSnapshot } from "../../packages/gittensory-miner/lib/live-issue-snapshot.js"; + +function graphqlResponse(body: unknown, status = 200) { + return async () => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }) as Response; +} + +describe("fetchLiveIssueSnapshot (#5132)", () => { + it("returns null for a malformed repoFullName or non-positive issue number", async () => { + expect(await fetchLiveIssueSnapshot("not-a-repo", 1, { fetchImpl: graphqlResponse({}) })).toBeNull(); + expect(await fetchLiveIssueSnapshot("acme/widgets", 0, { fetchImpl: graphqlResponse({}) })).toBeNull(); + expect(await fetchLiveIssueSnapshot("acme/widgets", -1, { fetchImpl: graphqlResponse({}) })).toBeNull(); + }); + + it("builds an open-issue snapshot with normalized, deduplicated-shape referencing PRs from GraphQL nodes", async () => { + let capturedUrl: string | undefined; + let capturedBody: string | undefined; + const fetchImpl = async (url: string, init: RequestInit) => { + capturedUrl = url; + capturedBody = init.body as string; + return { + ok: true, + status: 200, + json: async () => ({ + data: { + repository: { + issue: { + state: "OPEN", + closedByPullRequestsReferences: { + nodes: [ + { number: 42, state: "MERGED", author: { login: "alice" } }, + { number: 43, state: "OPEN", author: null }, + ], + }, + }, + }, + }, + }), + } as Response; + }; + + const snapshot = await fetchLiveIssueSnapshot("acme/widgets", 7, { githubToken: "tok", fetchImpl }); + + expect(snapshot).toEqual({ + state: "open", + referencingPrs: [ + { number: 42, state: "merged", authorLogin: "alice" }, + { number: 43, state: "open", authorLogin: "" }, + ], + }); + expect(capturedUrl).toBe("https://api.github.com/graphql"); + const parsedBody = JSON.parse(capturedBody ?? "{}"); + expect(parsedBody.variables).toEqual({ owner: "acme", repo: "widgets", number: 7, maxPrs: 50 }); + }); + + it("returns a closed-issue snapshot with no referencing PRs when the connection is empty", async () => { + const snapshot = await fetchLiveIssueSnapshot("acme/widgets", 7, { + fetchImpl: graphqlResponse({ + data: { repository: { issue: { state: "CLOSED", closedByPullRequestsReferences: { nodes: [] } } } }, + }), + }); + expect(snapshot).toEqual({ state: "closed", referencingPrs: [] }); + }); + + it("returns null when the HTTP response is not ok", async () => { + expect(await fetchLiveIssueSnapshot("acme/widgets", 7, { fetchImpl: graphqlResponse({}, 500) })).toBeNull(); + }); + + it("returns null when GitHub returns GraphQL errors", async () => { + expect( + await fetchLiveIssueSnapshot("acme/widgets", 7, { + fetchImpl: graphqlResponse({ errors: [{ message: "Could not resolve to an Issue" }] }), + }), + ).toBeNull(); + }); + + it("returns null when the issue is missing (repository or issue null) or its state is unrecognized", async () => { + expect( + await fetchLiveIssueSnapshot("acme/widgets", 7, { + fetchImpl: graphqlResponse({ data: { repository: { issue: null } } }), + }), + ).toBeNull(); + expect( + await fetchLiveIssueSnapshot("acme/widgets", 7, { + fetchImpl: graphqlResponse({ data: { repository: null } }), + }), + ).toBeNull(); + expect( + await fetchLiveIssueSnapshot("acme/widgets", 7, { + fetchImpl: graphqlResponse({ data: { repository: { issue: { state: "MERGED" } } } }), + }), + ).toBeNull(); + }); + + it("filters out malformed referencing-PR nodes without dropping valid siblings", async () => { + const snapshot = await fetchLiveIssueSnapshot("acme/widgets", 7, { + fetchImpl: graphqlResponse({ + data: { + repository: { + issue: { + state: "OPEN", + closedByPullRequestsReferences: { + nodes: [null, { number: 0, state: "OPEN" }, { number: 5, state: "bogus" }, { number: 9, state: "CLOSED" }], + }, + }, + }, + }, + }), + }); + expect(snapshot).toEqual({ state: "open", referencingPrs: [{ number: 9, state: "closed", authorLogin: "" }] }); + }); + + it("returns null when the response is not valid JSON or the fetch itself rejects", async () => { + expect( + await fetchLiveIssueSnapshot("acme/widgets", 7, { + fetchImpl: async () => ({ ok: true, status: 200, json: async () => { throw new Error("bad json"); } }) as unknown as Response, + }), + ).toBeNull(); + expect( + await fetchLiveIssueSnapshot("acme/widgets", 7, { + fetchImpl: async () => { + throw new Error("network down"); + }, + }), + ).toBeNull(); + }); + + it("sends an authorization header only when a token is provided", async () => { + let capturedHeaders: HeadersInit | undefined; + const fetchImpl = async (_url: string, init: RequestInit) => { + capturedHeaders = init.headers; + return { ok: true, status: 200, json: async () => ({ data: { repository: { issue: { state: "OPEN", closedByPullRequestsReferences: { nodes: [] } } } } }) } as Response; + }; + + await fetchLiveIssueSnapshot("acme/widgets", 7, { fetchImpl }); + expect((capturedHeaders as Record).authorization).toBeUndefined(); + + await fetchLiveIssueSnapshot("acme/widgets", 7, { githubToken: " secret-token ", fetchImpl }); + expect((capturedHeaders as Record).authorization).toBe("Bearer secret-token"); + }); + + it("respects a custom graphqlUrl override", async () => { + let capturedUrl: string | undefined; + await fetchLiveIssueSnapshot("acme/widgets", 7, { + graphqlUrl: "https://ghe.example.com/api/graphql", + fetchImpl: async (url: string) => { + capturedUrl = url; + return { ok: true, status: 200, json: async () => ({ data: { repository: { issue: { state: "OPEN", closedByPullRequestsReferences: { nodes: [] } } } } }) } as Response; + }, + }); + expect(capturedUrl).toBe("https://ghe.example.com/api/graphql"); + }); +});