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
8 changes: 8 additions & 0 deletions .gittensory-miner.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export {
parseMinerGoalSpecContent,
discoverMinerGoalSpecPath,
MINER_GOAL_SPEC_FILENAMES,
type FeasibilityGatePolicy,
type MinerGoalSpec,
type MinerIssueDiscoveryPolicy,
type ParsedMinerGoalSpec,
Expand Down
51 changes: 50 additions & 1 deletion packages/gittensory-engine/src/miner-goal-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -77,6 +95,7 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly<MinerGoalSpec> = 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;
Expand All @@ -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],
},
};
}

Expand Down Expand Up @@ -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<string, unknown>;
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)) {
Expand Down Expand Up @@ -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
);
}

Expand Down Expand Up @@ -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.");
Expand Down
27 changes: 27 additions & 0 deletions packages/gittensory-engine/test/miner-goal-spec-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 });
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
});

Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions packages/gittensory-engine/test/miner-goal-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ test("DEFAULT_MINER_GOAL_SPEC carries the documented safe defaults", () => {
blockedLabels: [],
maxConcurrentClaims: 1,
issueDiscoveryPolicy: "neutral",
feasibilityGate: { enabled: true, suppressedReasons: [] },
});
});

Expand All @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/gittensory-miner/docs/miner-goal-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 19 additions & 0 deletions packages/gittensory-miner/schema/miner-goal-spec.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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: []."
}
}
}
}
}
2 changes: 2 additions & 0 deletions test/unit/miner-goal-spec-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const SPEC_FIELDS = [
"blockedLabels",
"maxConcurrentClaims",
"issueDiscoveryPolicy",
"feasibilityGate",
] as const;

describe("miner goal spec docs (#2300)", () => {
Expand Down Expand Up @@ -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([]);
});
Expand Down
57 changes: 57 additions & 0 deletions test/unit/miner-goal-spec-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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.'],
});
Expand Down Expand Up @@ -131,6 +133,7 @@ describe("MinerGoalSpec parser (#2301)", () => {
blockedLabels: [123, " wontfix "],
maxConcurrentClaims: "3",
issueDiscoveryPolicy: "always",
feasibilityGate: "not a mapping",
});

expect(parsed).toEqual({
Expand All @@ -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),
Expand All @@ -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/**"],
Expand Down Expand Up @@ -183,6 +226,7 @@ describe("MinerGoalSpec parser (#2301)", () => {
blockedLabels: [],
maxConcurrentClaims: 1,
issueDiscoveryPolicy: "neutral",
feasibilityGate: { enabled: true, suppressedReasons: [] },
}),
).toEqual({
present: false,
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading