diff --git a/.gittensory-miner.yml.example b/.gittensory-miner.yml.example new file mode 100644 index 0000000000..4f7452f19f --- /dev/null +++ b/.gittensory-miner.yml.example @@ -0,0 +1,48 @@ +# ============================================================================ +# .gittensory-miner.yml - per-repo miner configuration (EXAMPLE) +# ============================================================================ +# A repo owner drops this file in to tell an autonomous Gittensory miner what to +# look for and how to behave when targeting their repo. It is the MINER-side +# analogue of `.gittensory.yml` (the review-side focus manifest). +# +# Every field is OPTIONAL and has a safe default (shown as "Default: X" below). +# The file is parsed tolerantly: an unknown key is ignored, and a single +# malformed field falls back to its default with a warning - a broken file never +# hard-fails the miner, it just falls back to the defaults. +# +# Discovery order (first match wins): +# .gittensory-miner.yml -> .github/gittensory-miner.yml +# -> .gittensory-miner.json -> .github/gittensory-miner.json +# +# Copy to `.gittensory-miner.yml` and edit. YAML or JSON are both accepted. + +# Whether this repo permits autonomous miners at all. Explicit OPT-OUT: a public +# repo with no file is still minable. Set false to halt all miner targeting. +# Boolean. Default: true. +minerEnabled: true + +# Work areas you want a miner to focus on; a candidate touching these is preferred. +# Glob list. Default: [] (no preference). +wantedPaths: + - "src/**" + +# Paths off-limits to a miner; a candidate touching one should be skipped. +# Glob list. Default: [] (nothing blocked). +blockedPaths: + - "vendor/**" + - ".github/workflows/**" + +# Issue/PR labels you prefer a miner to target; a candidate carrying one is favored. +# String list. Default: [] (no preference). +preferredLabels: + - bug + - enhancement + +# Maximum issues a single miner may hold claimed on this repo at once, so one +# miner cannot monopolize the queue. A positive integer (>= 1); a non-integer is +# floored. Default: 1. +maxConcurrentClaims: 1 + +# How strongly this repo encourages a miner to open discovery issues. +# Values: encouraged | neutral | discouraged. Default: neutral. +issueDiscoveryPolicy: neutral diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 3d4ae77e2c..fe29f83787 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -73,3 +73,8 @@ explicit opt-out), no path/label preferences, one concurrent claim, `neutral` di `parseMinerGoalSpec(raw)` and `parseMinerGoalSpecContent(content)` are the tolerant parser pair for that file. They never throw on malformed JSON/YAML; instead they return `{ present, spec, warnings }`, where `spec` is normalized to safe defaults and `warnings` explains any dropped or invalid fields. + +`discoverMinerGoalSpecPath(exists)` returns the first present file in the documented order (`MINER_GOAL_SPEC_FILENAMES`: +`.gittensory-miner.yml` → `.github/gittensory-miner.yml` → the `.json` variants). It is IO-free — the caller injects +the existence check — so a caller reads the returned path and feeds its content to `parseMinerGoalSpecContent`. See +`.gittensory-miner.yml.example` for the documented fields. diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index bfd54a096f..500be3e59d 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -22,6 +22,8 @@ export { DEFAULT_MINER_GOAL_SPEC, parseMinerGoalSpec, parseMinerGoalSpecContent, + discoverMinerGoalSpecPath, + MINER_GOAL_SPEC_FILENAMES, type MinerGoalSpec, type MinerIssueDiscoveryPolicy, type ParsedMinerGoalSpec, diff --git a/packages/gittensory-engine/src/miner-goal-spec.ts b/packages/gittensory-engine/src/miner-goal-spec.ts index 9bc433bd3c..757414b86b 100644 --- a/packages/gittensory-engine/src/miner-goal-spec.ts +++ b/packages/gittensory-engine/src/miner-goal-spec.ts @@ -246,3 +246,24 @@ export function parseMinerGoalSpecContent(content: string | null | undefined): P } return parseMinerGoalSpec(parsed); } + +/** + * The documented `.gittensory-miner` file-discovery order (first match wins), mirroring how `.gittensory.yml` is + * discovered: repo-root YAML, then `.github/` YAML, then the JSON variants. + */ +export const MINER_GOAL_SPEC_FILENAMES = [ + ".gittensory-miner.yml", + ".github/gittensory-miner.yml", + ".gittensory-miner.json", + ".github/gittensory-miner.json", +] as const; + +/** + * The first {@link MINER_GOAL_SPEC_FILENAMES} candidate that exists, or null. Pure: the caller injects the existence + * check (e.g. `fs.existsSync`) so this module stays IO-free and unit-testable. A caller reads the returned path and + * feeds its content to {@link parseMinerGoalSpecContent}. + */ +export function discoverMinerGoalSpecPath(exists: (path: string) => boolean): string | null { + for (const name of MINER_GOAL_SPEC_FILENAMES) if (exists(name)) return name; + return null; +} diff --git a/packages/gittensory-engine/test/miner-goal-spec-discovery.test.ts b/packages/gittensory-engine/test/miner-goal-spec-discovery.test.ts new file mode 100644 index 0000000000..5890c7d8f4 --- /dev/null +++ b/packages/gittensory-engine/test/miner-goal-spec-discovery.test.ts @@ -0,0 +1,47 @@ +// Tests for MinerGoalSpec file discovery (#2294). The tolerant parser itself is covered by +// miner-goal-spec-parse.test.ts / miner-goal-spec-parser.test.ts; this covers only the discovery order. Pure — +// the existence check is injected, so no filesystem is touched. Runs against compiled dist/. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { discoverMinerGoalSpecPath, MINER_GOAL_SPEC_FILENAMES } from "../dist/index.js"; + +test("MINER_GOAL_SPEC_FILENAMES lists the documented discovery order", () => { + assert.deepEqual([...MINER_GOAL_SPEC_FILENAMES], [ + ".gittensory-miner.yml", + ".github/gittensory-miner.yml", + ".gittensory-miner.json", + ".github/gittensory-miner.json", + ]); +}); + +test("discoverMinerGoalSpecPath: returns the first existing candidate, first match wins", () => { + assert.equal(discoverMinerGoalSpecPath(() => true), ".gittensory-miner.yml"); + // repo-root yml missing but the .github yml present → that one is chosen + assert.equal( + discoverMinerGoalSpecPath((p) => p !== ".gittensory-miner.yml"), + ".github/gittensory-miner.yml", + ); + // only a JSON variant present + assert.equal(discoverMinerGoalSpecPath((p) => p === ".gittensory-miner.json"), ".gittensory-miner.json"); +}); + +test("discoverMinerGoalSpecPath: short-circuits — stops probing once a candidate matches", () => { + const probed: string[] = []; + const result = discoverMinerGoalSpecPath((p) => { + probed.push(p); + return p === ".github/gittensory-miner.yml"; // the 2nd candidate matches + }); + assert.equal(result, ".github/gittensory-miner.yml"); + // only the first two candidates are probed; the later .json variants are never reached + assert.deepEqual(probed, [".gittensory-miner.yml", ".github/gittensory-miner.yml"]); +}); + +test("discoverMinerGoalSpecPath: returns null when no candidate exists, and never probes unlisted paths", () => { + const probed: string[] = []; + const result = discoverMinerGoalSpecPath((p) => { + probed.push(p); + return false; + }); + assert.equal(result, null); + assert.deepEqual(probed, [...MINER_GOAL_SPEC_FILENAMES]); // exactly the listed candidates, in order +});