diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index af57fa7a0e..411bb63869 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -16,6 +16,9 @@ Current scope is intentionally small: Environment variables read by the miner are documented in [`docs/env-reference.md`](docs/env-reference.md). Regenerate that file with `npm run miner:env-reference` from the repo root after adding or removing env reads. +Config precedence (`.gittensory-miner.yml` vs operator env vs CLI flags) is documented in +[`docs/config-precedence.md`](docs/config-precedence.md). + Real miner commands land in follow-up issues. The package also includes the first metadata-only discovery primitive: `fetchCandidateIssues` lists open issue diff --git a/packages/gittensory-miner/docs/config-precedence.md b/packages/gittensory-miner/docs/config-precedence.md new file mode 100644 index 0000000000..e6a995fc06 --- /dev/null +++ b/packages/gittensory-miner/docs/config-precedence.md @@ -0,0 +1,96 @@ +# Miner config precedence + +AMS does **not** have a single `config.js` resolver. Configuration is layered by concern across several modules under `packages/gittensory-miner/lib/` and `@jsonbored/gittensory-engine`. This document states the order each layer **actually implements today** — not an idealized or corrected order. + +## Configuration layers + +| Layer | Source | Scope | Typical modules | +| --- | --- | --- | --- | +| **Per-target-repo file** | `.gittensory-miner.yml` (or `.github/gittensory-miner.yml`, JSON variants) | One cloned target repo | `lib/miner-goal-spec.js`, engine `parseMinerGoalSpecContent` | +| **Operator env** | `GITTENSORY_MINER_*` / `MINER_*` | This miner process / fleet container | `lib/local-store.js`, `lib/governor-kill-switch.js`, `lib/attempt-cli.js`, … | +| **CLI flags** | `gittensory-miner …` argv | One invocation | `lib/attempt-cli.js`, `lib/discover-cli.js`, `lib/loop-cli.js`, … | +| **Operator file (not goal spec)** | `~/.config/gittensory-miner/.gittensory-ams.yml` | Operator execution policy | `lib/ams-policy.js` | + +`.gittensory-miner.yml` is **maintainer-authored in the target repo**. Operator env and CLI flags are **never overridden by a target repo's goal spec** for operator-owned policy (see `lib/ams-policy.js` header). + +## `.gittensory-miner.yml` file discovery + +First existing file wins (engine `MINER_GOAL_SPEC_FILENAMES`): + +1. `.gittensory-miner.yml` +2. `.github/gittensory-miner.yml` +3. `.gittensory-miner.json` +4. `.github/gittensory-miner.json` + +## Precedence by concern + +### Kill switch (halt miner writes) + +**Sources:** `GITTENSORY_MINER_KILL_SWITCH` (operator env) and `.gittensory-miner.yml` → `killSwitch.paused`. + +**Order (safest wins, engine `resolveMinerKillSwitch`):** + +1. Global env halt → scope `"global"` (always reported even when the repo yml also pauses). +2. Else per-repo yml `killSwitch.paused: true` → scope `"repo"`. +3. Else → scope `"none"`. + +There is **no CLI flag** for kill-switch today. `MINER_CODING_AGENT_PAUSED` is a separate axis (coding-agent spawn only) and does not change kill-switch scope. + +### Governor live write mode + +**Sources:** `GITTENSORY_MINER_LIVE_MODE=live` (operator env) and `.gittensory-miner.yml` → `execution.liveModeOptIn: live`. + +**Order (engine `resolveMinerActionMode`):** + +1. Kill switch active → `"paused"` (overrides any live opt-in). +2. Else **both** operator env **and** repo yml must equal the exact string `"live"` → `"live"`. +3. Else → `"dry_run"`. + +This is an **AND** requirement, not “last writer wins”. Either side missing or malformed → dry-run. + +There is **no CLI flag wired to governor live mode** today. `attempt --live` / `loop --live` affect coding-agent spawn mode only (below). + +### Coding-agent execution mode (spawn the driver?) + +**Sources:** `MINER_CODING_AGENT_PAUSED` (operator env) and `attempt|loop --live` (CLI, per invocation). + +**Order (engine `resolveCodingAgentExecutionMode`, wired in `lib/attempt-cli.js`):** + +1. Global env pause (`MINER_CODING_AGENT_PAUSED` truthy) → `"paused"`. +2. Else CLI `--live` absent → `agentDryRun: true` → `"dry_run"` (`attempt-cli.js` enforces dry-run default for #2342). +3. Else CLI `--live` present → `"live"`. + +There is **no `.gittensory-miner.yml` field** for coding-agent mode today. + +### Discover forge credential env var name + +**Sources:** `discover --token-env ` (CLI), programmatic `options.tokenEnv`, forge default (`GITHUB_TOKEN`). + +**Order (`lib/discover-cli.js`):** + +1. CLI `--token-env` +2. Else programmatic `options.tokenEnv` +3. Else `resolveForgeConfig(...).tokenEnvVar` (default `GITHUB_TOKEN`) + +There is **no `.gittensory-miner.yml` forge block** today; `--api-base-url` follows the same CLI → programmatic → default shape for the API host. + +### Local SQLite store paths + +**Sources:** per-store `GITTENSORY_MINER_*_DB` env var, then `GITTENSORY_MINER_CONFIG_DIR`, then XDG default (`lib/local-store.js`). + +Explicit per-store env **wins** over config dir; config dir **wins** over XDG. No CLI or goal-spec override. + +## Known gaps / inconsistencies + +- **No unified precedence** across yml + env + CLI for a single knob — each concern owns its resolver. +- **Live execution** spans two independent gates: coding-agent `--live` (spawn) vs governor env+yml (writes). Both must allow live for a full live open-pr attempt. +- **Forge tenant overrides** (`--api-base-url`, `--token-env`) are CLI/programmatic only; `.gittensory-miner.yml` cannot set them yet. +- **Operator AMS policy** (`.gittensory-ams.yml`) is separate from per-repo goal spec; goal spec never overrides operator policy. + +If a future change adds yml or CLI for a setting documented here as env-only, update this file and extend `test/unit/miner-config-precedence.test.ts`. + +## See also + +- [`miner-goal-spec.md`](miner-goal-spec.md) — goal-spec field reference +- [`env-reference.md`](env-reference.md) — generated operator env list +- ORB `.gittensory.yml` precedence (`yml > DB > defaults`) in the main app — analogous documentation style, different runtime diff --git a/packages/gittensory-miner/lib/config-precedence.d.ts b/packages/gittensory-miner/lib/config-precedence.d.ts new file mode 100644 index 0000000000..f88be25fb1 --- /dev/null +++ b/packages/gittensory-miner/lib/config-precedence.d.ts @@ -0,0 +1,25 @@ +import type { MinerActionMode, MinerKillSwitchScope } from "@jsonbored/gittensory-engine"; +import type { CodingAgentExecutionMode } from "@jsonbored/gittensory-engine"; +import type { ForgeConfig } from "./forge-config.js"; + +export function resolveDocumentedKillSwitchScope( + env: Record, + repoPaused: boolean, +): MinerKillSwitchScope; + +export function resolveDocumentedGovernorActionMode(input: { + killSwitchScope: MinerKillSwitchScope; + repoLiveModeOptIn?: unknown; + globalLiveModeOptIn: boolean; +}): MinerActionMode; + +export function resolveDocumentedCodingAgentMode( + env: Record, + cliLiveFlag: boolean, +): CodingAgentExecutionMode; + +export function resolveDocumentedDiscoverTokenEnvVar(input?: { + cliTokenEnv?: string | null; + optionsTokenEnv?: string | null; + forge?: Partial; +}): string; diff --git a/packages/gittensory-miner/lib/config-precedence.js b/packages/gittensory-miner/lib/config-precedence.js new file mode 100644 index 0000000000..be8badba36 --- /dev/null +++ b/packages/gittensory-miner/lib/config-precedence.js @@ -0,0 +1,38 @@ +/** Documented config-resolution helpers for precedence tests (#5198). Thin wrappers over existing resolvers — + * no new precedence rules. See `docs/config-precedence.md`. */ + +import { + isGlobalMinerCodingAgentPause, + resolveCodingAgentExecutionMode, + resolveMinerActionMode, + resolveMinerKillSwitch, +} from "@jsonbored/gittensory-engine"; +import { resolveForgeConfig } from "./forge-config.js"; + +/** Kill-switch scope from operator env + parsed goal-spec `killSwitch.paused`. */ +export function resolveDocumentedKillSwitchScope(env, repoPaused) { + const global = /^(1|true|yes|on)$/i.test(env.GITTENSORY_MINER_KILL_SWITCH ?? ""); + return resolveMinerKillSwitch({ global, repoPaused }); +} + +/** Governor action mode from kill-switch scope + env/yml live opt-ins. */ +export function resolveDocumentedGovernorActionMode(input) { + return resolveMinerActionMode(input); +} + +/** Coding-agent mode from operator env pause + CLI `--live` (via agentDryRun). */ +export function resolveDocumentedCodingAgentMode(env, cliLiveFlag) { + return resolveCodingAgentExecutionMode({ + globalPaused: isGlobalMinerCodingAgentPause(env), + agentDryRun: cliLiveFlag ? false : true, + }); +} + +/** Discover credential env var name: CLI > programmatic > forge default. Mirrors discover-cli.js. */ +export function resolveDocumentedDiscoverTokenEnvVar(input = {}) { + const parsedTokenEnv = input.cliTokenEnv ?? null; + const optionsTokenEnv = input.optionsTokenEnv ?? null; + if (parsedTokenEnv !== null) return parsedTokenEnv; + if (optionsTokenEnv !== null) return optionsTokenEnv; + return resolveForgeConfig(input.forge).tokenEnvVar; +} diff --git a/test/unit/miner-config-precedence.test.ts b/test/unit/miner-config-precedence.test.ts new file mode 100644 index 0000000000..e38008d584 --- /dev/null +++ b/test/unit/miner-config-precedence.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { + resolveDocumentedCodingAgentMode, + resolveDocumentedDiscoverTokenEnvVar, + resolveDocumentedGovernorActionMode, + resolveDocumentedKillSwitchScope, +} from "../../packages/gittensory-miner/lib/config-precedence.js"; +import { parseDiscoverArgs } from "../../packages/gittensory-miner/lib/discover-cli.js"; +import { parseAttemptArgs } from "../../packages/gittensory-miner/lib/attempt-cli.js"; +import { resolveCodingAgentModeFromConfig } from "../../packages/gittensory-engine/src/index"; + +describe("miner config precedence (#5198)", () => { + describe("kill switch: env global > .gittensory-miner.yml repo pause", () => { + it("global env wins when both global env and repo yml pause are set", () => { + expect(resolveDocumentedKillSwitchScope({ GITTENSORY_MINER_KILL_SWITCH: "true" }, true)).toBe("global"); + }); + + it("falls back to repo yml pause when global env is unset", () => { + expect(resolveDocumentedKillSwitchScope({}, true)).toBe("repo"); + expect(resolveDocumentedKillSwitchScope({}, false)).toBe("none"); + }); + }); + + describe("governor live mode: env AND yml both required", () => { + it("requires both operator env and repo yml opt-in for live", () => { + expect( + resolveDocumentedGovernorActionMode({ + killSwitchScope: "none", + repoLiveModeOptIn: "live", + globalLiveModeOptIn: true, + }), + ).toBe("live"); + }); + + it("falls back to dry_run when only env is set (yml missing)", () => { + expect( + resolveDocumentedGovernorActionMode({ + killSwitchScope: "none", + repoLiveModeOptIn: null, + globalLiveModeOptIn: true, + }), + ).toBe("dry_run"); + }); + + it("falls back to dry_run when only yml is set (env missing)", () => { + expect( + resolveDocumentedGovernorActionMode({ + killSwitchScope: "none", + repoLiveModeOptIn: "live", + globalLiveModeOptIn: false, + }), + ).toBe("dry_run"); + }); + }); + + describe("coding-agent mode: MINER_CODING_AGENT_PAUSED > CLI --live", () => { + it("env pause wins over CLI --live", () => { + expect(resolveDocumentedCodingAgentMode({ MINER_CODING_AGENT_PAUSED: "1" }, true)).toBe("paused"); + }); + + it("CLI --live absent defaults to dry_run without env pause", () => { + expect(resolveDocumentedCodingAgentMode({}, false)).toBe("dry_run"); + }); + + it("CLI --live enables live when env pause is unset", () => { + expect(resolveDocumentedCodingAgentMode({}, true)).toBe("live"); + }); + }); + + describe("discover token env var: CLI > programmatic > default (three sources)", () => { + it("CLI --token-env wins when CLI, programmatic, and default all differ", () => { + expect( + resolveDocumentedDiscoverTokenEnvVar({ + cliTokenEnv: "CLI_TOKEN_ENV", + optionsTokenEnv: "PROGRAMMATIC_TOKEN_ENV", + }), + ).toBe("CLI_TOKEN_ENV"); + }); + + it("falls back to programmatic when CLI is absent", () => { + expect( + resolveDocumentedDiscoverTokenEnvVar({ + cliTokenEnv: null, + optionsTokenEnv: "PROGRAMMATIC_TOKEN_ENV", + }), + ).toBe("PROGRAMMATIC_TOKEN_ENV"); + }); + + it("falls back to forge default GITHUB_TOKEN when only the default layer applies", () => { + expect(resolveDocumentedDiscoverTokenEnvVar({})).toBe("GITHUB_TOKEN"); + }); + }); + + describe("invariant: documented helpers match production call sites", () => { + it("attempt-cli wires CLI --live into resolveCodingAgentModeFromConfig the same way as config-precedence", () => { + const env = { MINER_CODING_AGENT_PAUSED: "0" }; + for (const live of [false, true]) { + const parsed = parseAttemptArgs(["acme/widgets", "7", "--miner-login", "miner", ...(live ? ["--live"] : [])]); + expect("error" in parsed).toBe(false); + if ("error" in parsed) continue; + const production = resolveCodingAgentModeFromConfig({ env, agentDryRun: !parsed.live }); + const documented = resolveDocumentedCodingAgentMode(env, parsed.live); + expect(documented).toBe(production); + } + }); + + it("discover-cli tokenEnv resolution matches config-precedence for parsed CLI args", () => { + const parsed = parseDiscoverArgs(["acme/widgets", "--token-env", "FORGE_PAT"]); + expect("error" in parsed).toBe(false); + if ("error" in parsed) return; + expect( + resolveDocumentedDiscoverTokenEnvVar({ + cliTokenEnv: parsed.tokenEnv ?? null, + optionsTokenEnv: "SHOULD_NOT_WIN", + }), + ).toBe("FORGE_PAT"); + }); + }); + + describe("canary: precedence assertions fail when expectations are wrong", () => { + it("does not treat a malformed yml live opt-in as live even with env set", () => { + expect( + resolveDocumentedGovernorActionMode({ + killSwitchScope: "none", + repoLiveModeOptIn: "yes", + globalLiveModeOptIn: true, + }), + ).toBe("dry_run"); + }); + + it("does not treat absent CLI --live as live coding-agent mode", () => { + expect(resolveDocumentedCodingAgentMode({}, false)).not.toBe("live"); + }); + }); +});