From 3e6177fe1cfda6fee862e33e314fcaf9cc97d4ad Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:45:43 -0700 Subject: [PATCH] feat(miner): wire the attempt CLI subcommand's real dependencies (#5132) Adds the `gittensory-miner attempt` subcommand: real GitHub-backed fetchLiveIssueSnapshot (GraphQL closedByPullRequestsReferences), a real executeLocalWrite (child_process, mirrors the #5131 driver spawn), and orchestration that gives worktree-allocator.js its first production caller and wires the four ledgers plus the #5131 driver and #5133 slop assessor into a real AttemptDeps object. runMinerAttempt still isn't reachable: it needs a SelfReviewContext (tracked by #5145, filed alongside this) and a coding-task-spec builder (title/instructions/acceptanceCriteriaPath, a second gap found while building this) that don't exist yet. Rather than fabricate either, the command builds and verifies every other real dependency and reports the block explicitly instead of calling runMinerAttempt with invalid input. --- .../gittensory-miner/bin/gittensory-miner.js | 7 + .../gittensory-miner/lib/attempt-cli.d.ts | 33 ++ packages/gittensory-miner/lib/attempt-cli.js | 221 ++++++++++++ packages/gittensory-miner/lib/cli.js | 1 + .../lib/execute-local-write.d.ts | 14 + .../lib/execute-local-write.js | 50 +++ .../lib/live-issue-snapshot.d.ts | 16 + .../lib/live-issue-snapshot.js | 109 ++++++ test/unit/miner-attempt-cli.test.ts | 319 ++++++++++++++++++ test/unit/miner-execute-local-write.test.ts | 56 +++ test/unit/miner-live-issue-snapshot.test.ts | 158 +++++++++ 11 files changed, 984 insertions(+) create mode 100644 packages/gittensory-miner/lib/attempt-cli.d.ts create mode 100644 packages/gittensory-miner/lib/attempt-cli.js create mode 100644 packages/gittensory-miner/lib/execute-local-write.d.ts create mode 100644 packages/gittensory-miner/lib/execute-local-write.js create mode 100644 packages/gittensory-miner/lib/live-issue-snapshot.d.ts create mode 100644 packages/gittensory-miner/lib/live-issue-snapshot.js create mode 100644 test/unit/miner-attempt-cli.test.ts create mode 100644 test/unit/miner-execute-local-write.test.ts create mode 100644 test/unit/miner-live-issue-snapshot.test.ts 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"); + }); +});