From f044dc5144ecd44915d4597e5df9a8cbeae67a52 Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Thu, 9 Jul 2026 23:25:01 +0400 Subject: [PATCH 1/2] feat(miner-config): parse a feasibilityGate policy block from .gittensory-miner.yml (#4275) --- .gittensory-miner.yml.example | 8 +++ packages/gittensory-engine/src/index.ts | 1 + .../gittensory-engine/src/miner-goal-spec.ts | 51 ++++++++++++++++- .../test/miner-goal-spec-parser.test.ts | 27 +++++++++ .../test/miner-goal-spec.test.ts | 4 ++ .../gittensory-miner/docs/miner-goal-spec.md | 7 +++ .../schema/miner-goal-spec.schema.json | 19 +++++++ test/unit/miner-goal-spec-doc.test.ts | 2 + test/unit/miner-goal-spec-parser.test.ts | 57 +++++++++++++++++++ .../unit/opportunity-branch-internals.test.ts | 1 + .../unit/opportunity-metadata-signals.test.ts | 1 + 11 files changed, 177 insertions(+), 1 deletion(-) diff --git a/.gittensory-miner.yml.example b/.gittensory-miner.yml.example index 8e1e5b6e5e..6ff294af05 100644 --- a/.gittensory-miner.yml.example +++ b/.gittensory-miner.yml.example @@ -52,3 +52,11 @@ maxConcurrentClaims: 1 # How strongly this repo encourages a miner to open discovery issues. # Values: encouraged | neutral | discouraged. Default: neutral. issueDiscoveryPolicy: neutral + +# Per-repo tuning for the feasibility gate a miner consults before starting work. +# `enabled` (boolean, default: true) turns the gate off entirely for this repo. +# `suppressedReasons` (string list, default: []) ignores specific avoid/raise +# reason codes (e.g. duplicate_cluster_high) from the gate's verdict. +feasibilityGate: + enabled: true + suppressedReasons: [] diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index af7bd6f173..4472195f4e 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -253,6 +253,7 @@ export { parseMinerGoalSpecContent, discoverMinerGoalSpecPath, MINER_GOAL_SPEC_FILENAMES, + type FeasibilityGatePolicy, 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 c76477dc6a..82b14103ed 100644 --- a/packages/gittensory-engine/src/miner-goal-spec.ts +++ b/packages/gittensory-engine/src/miner-goal-spec.ts @@ -10,6 +10,19 @@ import { parse as parseYaml } from "yaml"; /** How strongly opening discovery issues is encouraged for this repo. Mirrors the review-side policy vocabulary. */ export type MinerIssueDiscoveryPolicy = "encouraged" | "neutral" | "discouraged"; +/** Per-repo tuning for the feasibility gate (`buildFeasibilityVerdict`, see `feasibility.ts`) a miner consults + * before starting work. This is config-parsing surface only — it does not itself change the composer's + * behavior; a caller wiring the gate into a decision flow reads this policy and applies it. */ +export type FeasibilityGatePolicy = { + /** Whether this repo wants the feasibility gate consulted at all before a miner starts work. Setting this + * `false` lets a repo opt out of the gate entirely rather than tuning it. Default: true. */ + enabled: boolean; + /** Specific `buildFeasibilityVerdict` avoid/raise reason codes (e.g. `"duplicate_cluster_high"`) this repo + * wants ignored — for a repo that doesn't want duplicate-cluster signals to affect feasibility, for example. + * String list. Default: [] (nothing suppressed). */ + suppressedReasons: readonly string[]; +}; + /** Per-repo miner configuration parsed from `.gittensory-miner.yml`. See {@link DEFAULT_MINER_GOAL_SPEC}. */ export type MinerGoalSpec = { /** @@ -49,6 +62,11 @@ export type MinerGoalSpec = { * Default: neutral. */ issueDiscoveryPolicy: MinerIssueDiscoveryPolicy; + /** + * Per-repo tuning for the feasibility gate a miner consults before starting work. See {@link FeasibilityGatePolicy}. + * Default: { enabled: true, suppressedReasons: [] }. + */ + feasibilityGate: FeasibilityGatePolicy; }; /** The tolerant parser result for `.gittensory-miner.yml`: the normalized spec plus parse warnings and whether the @@ -77,6 +95,7 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly = Object.freeze({ blockedLabels: Object.freeze([]), maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: Object.freeze({ enabled: true, suppressedReasons: Object.freeze([]) }), }); const MAX_MINER_GOAL_SPEC_BYTES = 32_768; @@ -90,6 +109,10 @@ function cloneDefaultMinerGoalSpec(): MinerGoalSpec { blockedPaths: [...DEFAULT_MINER_GOAL_SPEC.blockedPaths], preferredLabels: [...DEFAULT_MINER_GOAL_SPEC.preferredLabels], blockedLabels: [...DEFAULT_MINER_GOAL_SPEC.blockedLabels], + feasibilityGate: { + enabled: DEFAULT_MINER_GOAL_SPEC.feasibilityGate.enabled, + suppressedReasons: [...DEFAULT_MINER_GOAL_SPEC.feasibilityGate.suppressedReasons], + }, }; } @@ -149,6 +172,24 @@ function normalizeIssueDiscoveryPolicy( return fallback; } +function normalizeFeasibilityGatePolicy( + value: unknown, + field: string, + fallback: FeasibilityGatePolicy, + warnings: string[], +): FeasibilityGatePolicy { + if (value === undefined || value === null) return fallback; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`MinerGoalSpec field "${field}" must be a mapping; falling back to defaults.`); + return fallback; + } + const record = value as Record; + return { + enabled: normalizeBoolean(record.enabled, `${field}.enabled`, fallback.enabled, warnings), + suppressedReasons: normalizeStringList(record.suppressedReasons, `${field}.suppressedReasons`, warnings), + }; +} + function normalizePositiveInteger(value: unknown, field: string, fallback: number, warnings: string[]): number { if (value === undefined || value === null) return fallback; if (typeof value !== "number" || !Number.isFinite(value)) { @@ -181,7 +222,9 @@ function hasConfiguredGoalFields(spec: MinerGoalSpec): boolean { spec.preferredLabels.length > 0 || spec.blockedLabels.length > 0 || spec.maxConcurrentClaims !== DEFAULT_MINER_GOAL_SPEC.maxConcurrentClaims || - spec.issueDiscoveryPolicy !== DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy + spec.issueDiscoveryPolicy !== DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy || + spec.feasibilityGate.enabled !== DEFAULT_MINER_GOAL_SPEC.feasibilityGate.enabled || + spec.feasibilityGate.suppressedReasons.length > 0 ); } @@ -222,6 +265,12 @@ export function parseMinerGoalSpec(raw: unknown): ParsedMinerGoalSpec { DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy, warnings, ), + feasibilityGate: normalizeFeasibilityGatePolicy( + record.feasibilityGate, + "feasibilityGate", + DEFAULT_MINER_GOAL_SPEC.feasibilityGate, + warnings, + ), }; if (!hasConfiguredGoalFields(spec)) { warnings.push("MinerGoalSpec contained no recognized non-default goal fields; falling back to safe defaults."); diff --git a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts index 502ef8b92f..f8e2307677 100644 --- a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts @@ -34,6 +34,7 @@ test("parseMinerGoalSpec: valid raw config normalizes every field and keeps non- blockedLabels: ["duplicate", " duplicate "], maxConcurrentClaims: 2.9, issueDiscoveryPolicy: "encouraged", + feasibilityGate: { enabled: false, suppressedReasons: ["duplicate_cluster_high"] }, }); assert.equal(parsed.present, true); @@ -45,10 +46,32 @@ test("parseMinerGoalSpec: valid raw config normalizes every field and keeps non- blockedLabels: ["duplicate"], maxConcurrentClaims: 2, issueDiscoveryPolicy: "encouraged", + feasibilityGate: { enabled: false, suppressedReasons: ["duplicate_cluster_high"] }, }); assert.deepEqual(parsed.warnings, []); }); +test("parseMinerGoalSpec: feasibilityGate sub-fields normalize independently and reject a non-mapping value", () => { + const valid = parseMinerGoalSpec({ + wantedPaths: ["src/**"], + feasibilityGate: { enabled: false, suppressedReasons: ["issue_missing"] }, + }); + assert.deepEqual(valid.spec.feasibilityGate, { enabled: false, suppressedReasons: ["issue_missing"] }); + assert.deepEqual(valid.warnings, []); + + const malformed = parseMinerGoalSpec({ + wantedPaths: ["src/**"], + feasibilityGate: { enabled: "nope", suppressedReasons: "not a list" }, + }); + assert.deepEqual(malformed.spec.feasibilityGate, { enabled: true, suppressedReasons: [] }); + assert.match(malformed.warnings.join(" "), /feasibilityGate\.enabled/i); + assert.match(malformed.warnings.join(" "), /feasibilityGate\.suppressedReasons/i); + + const arrayValue = parseMinerGoalSpec({ wantedPaths: ["src/**"], feasibilityGate: ["not", "a", "mapping"] }); + assert.deepEqual(arrayValue.spec.feasibilityGate, { enabled: true, suppressedReasons: [] }); + assert.match(arrayValue.warnings.join(" "), /feasibilityGate.*must be a mapping/i); +}); + test("parseMinerGoalSpec: exactly 100 unique entries are accepted without a cap warning", () => { const wantedPaths = Array.from({ length: 100 }, (_, index) => `src/${index}.ts`); const parsed = parseMinerGoalSpec({ wantedPaths }); @@ -119,6 +142,7 @@ test("parseMinerGoalSpec: malformed fields fall back independently with targeted blockedLabels: [123, " wontfix "], maxConcurrentClaims: 0.9, issueDiscoveryPolicy: "always", + feasibilityGate: "not a mapping", }); assert.equal(parsed.present, true); @@ -130,6 +154,7 @@ test("parseMinerGoalSpec: malformed fields fall back independently with targeted blockedLabels: ["wontfix"], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: { enabled: true, suppressedReasons: [] }, }); const warningText = parsed.warnings.join(" "); assert.match(warningText, /minerEnabled/i); @@ -139,6 +164,7 @@ test("parseMinerGoalSpec: malformed fields fall back independently with targeted assert.match(warningText, /blockedLabels/i); assert.match(warningText, /maxConcurrentClaims/i); assert.match(warningText, /issueDiscoveryPolicy/i); + assert.match(warningText, /feasibilityGate/i); assert.match(warningText, /truncated an over-long entry/i); }); @@ -156,6 +182,7 @@ test("parseMinerGoalSpec: unknown-only or default-only content stays absent with blockedLabels: [], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: { enabled: true, suppressedReasons: [] }, }); assert.equal(explicitDefaults.present, false); assert.deepEqual(explicitDefaults.spec, DEFAULT_MINER_GOAL_SPEC); diff --git a/packages/gittensory-engine/test/miner-goal-spec.test.ts b/packages/gittensory-engine/test/miner-goal-spec.test.ts index cd85337f02..b4b7d5d89b 100644 --- a/packages/gittensory-engine/test/miner-goal-spec.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec.test.ts @@ -19,6 +19,7 @@ test("DEFAULT_MINER_GOAL_SPEC carries the documented safe defaults", () => { blockedLabels: [], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: { enabled: true, suppressedReasons: [] }, }); }); @@ -28,12 +29,15 @@ test("DEFAULT_MINER_GOAL_SPEC is deep-frozen so the shared singleton can't be mu assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.blockedPaths)); assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.preferredLabels)); assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.blockedLabels)); + assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.feasibilityGate)); + assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.feasibilityGate.suppressedReasons)); }); test("DEFAULT_MINER_GOAL_SPEC exposes exactly the specified field surface", () => { assert.deepEqual(Object.keys(DEFAULT_MINER_GOAL_SPEC).sort(), [ "blockedLabels", "blockedPaths", + "feasibilityGate", "issueDiscoveryPolicy", "maxConcurrentClaims", "minerEnabled", diff --git a/packages/gittensory-miner/docs/miner-goal-spec.md b/packages/gittensory-miner/docs/miner-goal-spec.md index 777721d721..037bceb4d7 100644 --- a/packages/gittensory-miner/docs/miner-goal-spec.md +++ b/packages/gittensory-miner/docs/miner-goal-spec.md @@ -49,3 +49,10 @@ Maximum issues one miner may hold claimed on this repo at once. ### `issueDiscoveryPolicy` (`encouraged` | `neutral` | `discouraged`, default: `neutral`) How strongly this repo encourages a miner to open discovery issues. + +### `feasibilityGate` (object, default: `{ enabled: true, suppressedReasons: [] }`) + +Per-repo tuning for the feasibility gate (`buildFeasibilityVerdict`) a miner consults before starting work. This is config-parsing surface only — a caller wiring the gate into a decision flow is responsible for reading and applying this policy. + +- `enabled` (boolean, default: `true`) — whether the feasibility gate is consulted at all before a miner starts work. +- `suppressedReasons` (string list, default: `[]`) — specific avoid/raise reason codes (e.g. `duplicate_cluster_high`) this repo wants ignored. diff --git a/packages/gittensory-miner/schema/miner-goal-spec.schema.json b/packages/gittensory-miner/schema/miner-goal-spec.schema.json index 14340a3e27..915b79645b 100644 --- a/packages/gittensory-miner/schema/miner-goal-spec.schema.json +++ b/packages/gittensory-miner/schema/miner-goal-spec.schema.json @@ -46,6 +46,25 @@ "enum": ["encouraged", "neutral", "discouraged"], "default": "neutral", "description": "How strongly opening discovery issues is encouraged. Default: neutral." + }, + "feasibilityGate": { + "type": "object", + "additionalProperties": true, + "default": { "enabled": true, "suppressedReasons": [] }, + "description": "Per-repo tuning for the feasibility gate a miner consults before starting work. Default: { enabled: true, suppressedReasons: [] }.", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether the feasibility gate is consulted at all before a miner starts work. Default: true." + }, + "suppressedReasons": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "default": [], + "description": "buildFeasibilityVerdict avoid/raise reason codes this repo wants ignored. Default: []." + } + } } } } diff --git a/test/unit/miner-goal-spec-doc.test.ts b/test/unit/miner-goal-spec-doc.test.ts index a151eefe6b..d48bc64024 100644 --- a/test/unit/miner-goal-spec-doc.test.ts +++ b/test/unit/miner-goal-spec-doc.test.ts @@ -17,6 +17,7 @@ const SPEC_FIELDS = [ "blockedLabels", "maxConcurrentClaims", "issueDiscoveryPolicy", + "feasibilityGate", ] as const; describe("miner goal spec docs (#2300)", () => { @@ -53,6 +54,7 @@ describe("miner goal spec docs (#2300)", () => { blockedLabels: ["wontfix", "duplicate"], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: { enabled: true, suppressedReasons: [] }, }); expect(parsed.warnings).toEqual([]); }); diff --git a/test/unit/miner-goal-spec-parser.test.ts b/test/unit/miner-goal-spec-parser.test.ts index 67684ffd93..9a5ba1dca0 100644 --- a/test/unit/miner-goal-spec-parser.test.ts +++ b/test/unit/miner-goal-spec-parser.test.ts @@ -41,6 +41,7 @@ describe("MinerGoalSpec parser (#2301)", () => { blockedLabels: ["duplicate", " duplicate "], maxConcurrentClaims: 2.9, issueDiscoveryPolicy: "encouraged", + feasibilityGate: { enabled: false, suppressedReasons: ["duplicate_cluster_high", "duplicate_cluster_high"] }, }); expect(parsed).toEqual({ @@ -53,6 +54,7 @@ describe("MinerGoalSpec parser (#2301)", () => { blockedLabels: ["duplicate"], maxConcurrentClaims: 2, issueDiscoveryPolicy: "encouraged", + feasibilityGate: { enabled: false, suppressedReasons: ["duplicate_cluster_high"] }, }, warnings: ['MinerGoalSpec field "blockedPaths" truncated an over-long entry.'], }); @@ -131,6 +133,7 @@ describe("MinerGoalSpec parser (#2301)", () => { blockedLabels: [123, " wontfix "], maxConcurrentClaims: "3", issueDiscoveryPolicy: "always", + feasibilityGate: "not a mapping", }); expect(parsed).toEqual({ @@ -143,6 +146,7 @@ describe("MinerGoalSpec parser (#2301)", () => { blockedLabels: ["wontfix"], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: { enabled: true, suppressedReasons: [] }, }, warnings: expect.arrayContaining([ expect.stringMatching(/minerEnabled/i), @@ -152,10 +156,49 @@ describe("MinerGoalSpec parser (#2301)", () => { expect.stringMatching(/blockedLabels/i), expect.stringMatching(/maxConcurrentClaims/i), expect.stringMatching(/issueDiscoveryPolicy/i), + expect.stringMatching(/feasibilityGate/i), ]), }); }); + it("normalizes nested feasibilityGate sub-fields independently and rejects a non-mapping value", () => { + const validSubFields = parseMinerGoalSpec({ + wantedPaths: ["src/**"], + feasibilityGate: { enabled: false, suppressedReasons: ["duplicate_cluster_high"] }, + }); + expect(validSubFields.spec.feasibilityGate).toEqual({ + enabled: false, + suppressedReasons: ["duplicate_cluster_high"], + }); + expect(validSubFields.warnings).toEqual([]); + + const malformedSubFields = parseMinerGoalSpec({ + wantedPaths: ["src/**"], + feasibilityGate: { enabled: "nope", suppressedReasons: "not a list" }, + }); + expect(malformedSubFields.spec.feasibilityGate).toEqual({ enabled: true, suppressedReasons: [] }); + expect(malformedSubFields.warnings).toEqual( + expect.arrayContaining([ + expect.stringMatching(/feasibilityGate\.enabled/i), + expect.stringMatching(/feasibilityGate\.suppressedReasons/i), + ]), + ); + + const arrayValue = parseMinerGoalSpec({ wantedPaths: ["src/**"], feasibilityGate: ["not", "a", "mapping"] }); + expect(arrayValue.spec.feasibilityGate).toEqual({ enabled: true, suppressedReasons: [] }); + expect(arrayValue.warnings.join(" ")).toMatch(/feasibilityGate.*must be a mapping/i); + }); + + it("a feasibilityGate policy alone (all other fields default) marks the spec present", () => { + const parsed = parseMinerGoalSpec({ feasibilityGate: { enabled: false, suppressedReasons: [] } }); + expect(parsed.present).toBe(true); + expect(parsed.spec.feasibilityGate).toEqual({ enabled: false, suppressedReasons: [] }); + + const suppressedOnly = parseMinerGoalSpec({ feasibilityGate: { suppressedReasons: ["issue_missing"] } }); + expect(suppressedOnly.present).toBe(true); + expect(suppressedOnly.spec.feasibilityGate).toEqual({ enabled: true, suppressedReasons: ["issue_missing"] }); + }); + it("rejects claim counts below one after flooring", () => { const parsed = parseMinerGoalSpec({ wantedPaths: ["src/**"], @@ -183,6 +226,7 @@ describe("MinerGoalSpec parser (#2301)", () => { blockedLabels: [], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: { enabled: true, suppressedReasons: [] }, }), ).toEqual({ present: false, @@ -225,6 +269,19 @@ describe("MinerGoalSpec parser (#2301)", () => { }, warnings: [], }); + + expect( + parseMinerGoalSpecContent( + "feasibilityGate:\n enabled: false\n suppressedReasons:\n - duplicate_cluster_high\n", + ), + ).toEqual({ + present: true, + spec: { + ...DEFAULT_MINER_GOAL_SPEC, + feasibilityGate: { enabled: false, suppressedReasons: ["duplicate_cluster_high"] }, + }, + warnings: [], + }); }); it("treats empty, malformed, non-mapping, and oversized content as absent", () => { diff --git a/test/unit/opportunity-branch-internals.test.ts b/test/unit/opportunity-branch-internals.test.ts index 208d2c1c60..d337726d5e 100644 --- a/test/unit/opportunity-branch-internals.test.ts +++ b/test/unit/opportunity-branch-internals.test.ts @@ -73,6 +73,7 @@ describe("opportunity branch internals", () => { blockedLabels: [], maxConcurrentClaims: 1, issueDiscoveryPolicy: "encouraged", + feasibilityGate: { enabled: true, suppressedReasons: [] }, }, }, }).preferredLabels, diff --git a/test/unit/opportunity-metadata-signals.test.ts b/test/unit/opportunity-metadata-signals.test.ts index b2d5dafa5b..4c70f7ebbc 100644 --- a/test/unit/opportunity-metadata-signals.test.ts +++ b/test/unit/opportunity-metadata-signals.test.ts @@ -66,6 +66,7 @@ describe("opportunity metadata signals", () => { blockedLabels: [], maxConcurrentClaims: 1, issueDiscoveryPolicy: "encouraged", + feasibilityGate: { enabled: true, suppressedReasons: [] }, }, }, }, From ee5d1bd397564fb5587583853c6c98946a6f54a1 Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Thu, 9 Jul 2026 23:28:58 +0400 Subject: [PATCH 2/2] test(miner-config): fix feasibilityGate literal after rebase --- test/unit/miner-opportunity-ranker.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/unit/miner-opportunity-ranker.test.ts b/test/unit/miner-opportunity-ranker.test.ts index 1c300d35b8..4cb9e8e35b 100644 --- a/test/unit/miner-opportunity-ranker.test.ts +++ b/test/unit/miner-opportunity-ranker.test.ts @@ -142,6 +142,7 @@ describe("rankCandidateIssues (#2302 follow-up)", () => { blockedLabels: [], maxConcurrentClaims: 2, issueDiscoveryPolicy: "neutral", + feasibilityGate: { enabled: true, suppressedReasons: [] }, }, }, }); @@ -160,6 +161,7 @@ describe("rankCandidateIssues (#2302 follow-up)", () => { blockedLabels: [], maxConcurrentClaims: 2, issueDiscoveryPolicy: "neutral", + feasibilityGate: { enabled: true, suppressedReasons: [] }, }, }, }); @@ -217,6 +219,7 @@ describe("rankCandidateIssues (#2302 follow-up)", () => { blockedLabels: [], maxConcurrentClaims: 2, issueDiscoveryPolicy: "neutral", + feasibilityGate: { enabled: true, suppressedReasons: [] }, }, }, },