Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions packages/gittensory-miner/docs/config-precedence.md
Original file line number Diff line number Diff line change
@@ -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 <cmd> …` 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 <VAR>` (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
25 changes: 25 additions & 0 deletions packages/gittensory-miner/lib/config-precedence.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>,
repoPaused: boolean,
): MinerKillSwitchScope;

export function resolveDocumentedGovernorActionMode(input: {
killSwitchScope: MinerKillSwitchScope;
repoLiveModeOptIn?: unknown;
globalLiveModeOptIn: boolean;
}): MinerActionMode;

export function resolveDocumentedCodingAgentMode(
env: Record<string, string | undefined>,
cliLiveFlag: boolean,
): CodingAgentExecutionMode;

export function resolveDocumentedDiscoverTokenEnvVar(input?: {
cliTokenEnv?: string | null;
optionsTokenEnv?: string | null;
forge?: Partial<ForgeConfig>;
}): string;
38 changes: 38 additions & 0 deletions packages/gittensory-miner/lib/config-precedence.js
Original file line number Diff line number Diff line change
@@ -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;
}
140 changes: 140 additions & 0 deletions test/unit/miner-config-precedence.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});