diff --git a/.gittensory-miner.yml.example b/.gittensory-miner.yml.example index 4f7452f19f..8e1e5b6e5e 100644 --- a/.gittensory-miner.yml.example +++ b/.gittensory-miner.yml.example @@ -38,6 +38,12 @@ preferredLabels: - bug - enhancement +# Issue/PR labels a miner must not target; candidates carrying one are skipped. +# String list. Default: [] (nothing blocked). +blockedLabels: + - wontfix + - duplicate + # 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. diff --git a/packages/gittensory-engine/src/miner-goal-lane-fit.ts b/packages/gittensory-engine/src/miner-goal-lane-fit.ts index c382dcb37e..f36900a69e 100644 --- a/packages/gittensory-engine/src/miner-goal-lane-fit.ts +++ b/packages/gittensory-engine/src/miner-goal-lane-fit.ts @@ -26,6 +26,11 @@ export function computeMinerGoalLaneFit( spec: MinerGoalSpec, ): number { const issueLabels = normalizeLabels(issue.labels); + const blocked = normalizeLabels(spec.blockedLabels); + if (blocked.length > 0 && blocked.some((label) => issueLabels.includes(label))) { + return 0; + } + const preferred = normalizeLabels(spec.preferredLabels); let score: number; diff --git a/packages/gittensory-engine/src/miner-goal-spec-parse.ts b/packages/gittensory-engine/src/miner-goal-spec-parse.ts index a2b177ab58..0149cf432c 100644 --- a/packages/gittensory-engine/src/miner-goal-spec-parse.ts +++ b/packages/gittensory-engine/src/miner-goal-spec-parse.ts @@ -20,6 +20,7 @@ function freezeSpec(spec: MinerGoalSpec): Readonly { wantedPaths: Object.freeze([...spec.wantedPaths]), blockedPaths: Object.freeze([...spec.blockedPaths]), preferredLabels: Object.freeze([...spec.preferredLabels]), + blockedLabels: Object.freeze([...spec.blockedLabels]), }); } @@ -96,6 +97,7 @@ export function parseMinerGoalSpec(raw: unknown): MinerGoalSpecParseResult { wantedPaths: parseStringList(record.wantedPaths, "wantedPaths", warnings), blockedPaths: parseStringList(record.blockedPaths, "blockedPaths", warnings), preferredLabels: parseStringList(record.preferredLabels, "preferredLabels", warnings), + blockedLabels: parseStringList(record.blockedLabels, "blockedLabels", warnings), maxConcurrentClaims: parseClaims(record.maxConcurrentClaims, warnings), issueDiscoveryPolicy: parsePolicy(record.issueDiscoveryPolicy, warnings), }); diff --git a/packages/gittensory-engine/src/miner-goal-spec.ts b/packages/gittensory-engine/src/miner-goal-spec.ts index 757414b86b..34e8b58ca8 100644 --- a/packages/gittensory-engine/src/miner-goal-spec.ts +++ b/packages/gittensory-engine/src/miner-goal-spec.ts @@ -33,6 +33,11 @@ export type MinerGoalSpec = { * Default: [] (no preference). */ preferredLabels: readonly string[]; + /** + * Issue/PR labels a miner must not target; a candidate carrying one should be skipped. String list. + * Default: [] (nothing blocked). + */ + blockedLabels: readonly string[]; /** * Maximum number of issues a single miner may hold claimed on this repo at once, so one miner cannot monopolize * a repo's queue. A positive integer (`>= 1`); the parser is expected to floor a non-integer toward zero @@ -69,6 +74,7 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly = Object.freeze({ wantedPaths: Object.freeze([]), blockedPaths: Object.freeze([]), preferredLabels: Object.freeze([]), + blockedLabels: Object.freeze([]), maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", }); @@ -83,6 +89,7 @@ function cloneDefaultMinerGoalSpec(): MinerGoalSpec { wantedPaths: [...DEFAULT_MINER_GOAL_SPEC.wantedPaths], blockedPaths: [...DEFAULT_MINER_GOAL_SPEC.blockedPaths], preferredLabels: [...DEFAULT_MINER_GOAL_SPEC.preferredLabels], + blockedLabels: [...DEFAULT_MINER_GOAL_SPEC.blockedLabels], }; } @@ -172,6 +179,7 @@ function hasConfiguredGoalFields(spec: MinerGoalSpec): boolean { spec.wantedPaths.length > 0 || spec.blockedPaths.length > 0 || spec.preferredLabels.length > 0 || + spec.blockedLabels.length > 0 || spec.maxConcurrentClaims !== DEFAULT_MINER_GOAL_SPEC.maxConcurrentClaims || spec.issueDiscoveryPolicy !== DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy ); @@ -201,6 +209,7 @@ export function parseMinerGoalSpec(raw: unknown): ParsedMinerGoalSpec { wantedPaths: normalizeStringList(record.wantedPaths, "wantedPaths", warnings), blockedPaths: normalizeStringList(record.blockedPaths, "blockedPaths", warnings), preferredLabels: normalizeStringList(record.preferredLabels, "preferredLabels", warnings), + blockedLabels: normalizeStringList(record.blockedLabels, "blockedLabels", warnings), maxConcurrentClaims: normalizePositiveInteger( record.maxConcurrentClaims, "maxConcurrentClaims", diff --git a/packages/gittensory-engine/test/miner-goal-lane-fit.test.ts b/packages/gittensory-engine/test/miner-goal-lane-fit.test.ts index 550309795f..0350f8b41f 100644 --- a/packages/gittensory-engine/test/miner-goal-lane-fit.test.ts +++ b/packages/gittensory-engine/test/miner-goal-lane-fit.test.ts @@ -36,6 +36,12 @@ test("computeMinerGoalLaneFit applies issueDiscoveryPolicy modifiers", () => { assert.equal(computeMinerGoalLaneFit({ labels: ["feature"] }, discouraged), 1); }); +test("computeMinerGoalLaneFit returns 0 when a blocked label matches case-insensitively", () => { + const spec = { ...DEFAULT_MINER_GOAL_SPEC, blockedLabels: ["wontfix"] }; + assert.equal(computeMinerGoalLaneFit({ labels: ["WontFix"] }, spec), 0); + assert.equal(computeMinerGoalLaneFit({ labels: ["bug"] }, spec), 1); +}); + test("computeMinerGoalLaneFit ignores malformed label entries safely", () => { assert.equal( computeMinerGoalLaneFit({ labels: ["bug", "", 42 as unknown as string, " "] }, { diff --git a/packages/gittensory-engine/test/miner-goal-spec-parse.test.ts b/packages/gittensory-engine/test/miner-goal-spec-parse.test.ts index b6c43406d0..fa50c0c48d 100644 --- a/packages/gittensory-engine/test/miner-goal-spec-parse.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec-parse.test.ts @@ -19,6 +19,7 @@ test("parseMinerGoalSpec coerces a full valid object and marks present", () => { wantedPaths: [" src/** ", "src/**"], blockedPaths: ["dist/**"], preferredLabels: [" bug ", "feature"], + blockedLabels: [" wontfix ", "duplicate"], maxConcurrentClaims: 3, issueDiscoveryPolicy: "ENCOURAGED", }); @@ -29,6 +30,7 @@ test("parseMinerGoalSpec coerces a full valid object and marks present", () => { wantedPaths: ["src/**"], blockedPaths: ["dist/**"], preferredLabels: ["bug", "feature"], + blockedLabels: ["wontfix", "duplicate"], maxConcurrentClaims: 3, issueDiscoveryPolicy: "encouraged", }); 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 a3617ec70d..d3b3856383 100644 --- a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts @@ -31,6 +31,7 @@ test("parseMinerGoalSpec: valid raw config normalizes every field and keeps non- wantedPaths: ["src/**", " src/** ", "", "docs/**"], blockedPaths: ["dist/**"], preferredLabels: ["help wanted", "help wanted", "gittensor:feature"], + blockedLabels: ["duplicate", " duplicate "], maxConcurrentClaims: 2.9, issueDiscoveryPolicy: "encouraged", }); @@ -41,6 +42,7 @@ test("parseMinerGoalSpec: valid raw config normalizes every field and keeps non- wantedPaths: ["src/**", "docs/**"], blockedPaths: ["dist/**"], preferredLabels: ["help wanted", "gittensor:feature"], + blockedLabels: ["duplicate"], maxConcurrentClaims: 2, issueDiscoveryPolicy: "encouraged", }); @@ -63,6 +65,7 @@ test("parseMinerGoalSpec: malformed fields fall back independently with targeted wantedPaths: "src/**", blockedPaths: [123, " dist/** ", "", longEntry], preferredLabels: [false, "bugfix"], + blockedLabels: [123, " wontfix "], maxConcurrentClaims: 0.9, issueDiscoveryPolicy: "always", }); @@ -73,6 +76,7 @@ test("parseMinerGoalSpec: malformed fields fall back independently with targeted wantedPaths: [], blockedPaths: ["dist/**", longEntry.slice(0, 256)], preferredLabels: ["bugfix"], + blockedLabels: ["wontfix"], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", }); @@ -81,6 +85,7 @@ test("parseMinerGoalSpec: malformed fields fall back independently with targeted assert.match(warningText, /wantedPaths/i); assert.match(warningText, /blockedPaths/i); assert.match(warningText, /preferredLabels/i); + assert.match(warningText, /blockedLabels/i); assert.match(warningText, /maxConcurrentClaims/i); assert.match(warningText, /issueDiscoveryPolicy/i); assert.match(warningText, /truncated an over-long entry/i); @@ -97,6 +102,7 @@ test("parseMinerGoalSpec: unknown-only or default-only content stays absent with wantedPaths: [], blockedPaths: [], preferredLabels: [], + blockedLabels: [], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", }); diff --git a/packages/gittensory-engine/test/miner-goal-spec.test.ts b/packages/gittensory-engine/test/miner-goal-spec.test.ts index 329b4a933f..cd85337f02 100644 --- a/packages/gittensory-engine/test/miner-goal-spec.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec.test.ts @@ -16,6 +16,7 @@ test("DEFAULT_MINER_GOAL_SPEC carries the documented safe defaults", () => { wantedPaths: [], blockedPaths: [], preferredLabels: [], + blockedLabels: [], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", }); @@ -26,10 +27,12 @@ test("DEFAULT_MINER_GOAL_SPEC is deep-frozen so the shared singleton can't be mu assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.wantedPaths)); 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)); }); test("DEFAULT_MINER_GOAL_SPEC exposes exactly the specified field surface", () => { assert.deepEqual(Object.keys(DEFAULT_MINER_GOAL_SPEC).sort(), [ + "blockedLabels", "blockedPaths", "issueDiscoveryPolicy", "maxConcurrentClaims", diff --git a/test/unit/miner-goal-lane-fit.test.ts b/test/unit/miner-goal-lane-fit.test.ts new file mode 100644 index 0000000000..44e9ea321f --- /dev/null +++ b/test/unit/miner-goal-lane-fit.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + computeMinerGoalLaneFit, + DEFAULT_MINER_GOAL_SPEC, + isMinerRepoTargetable, +} from "../../packages/gittensory-engine/src/index"; + +describe("computeMinerGoalLaneFit", () => { + it("respects minerEnabled opt-out", () => { + expect(isMinerRepoTargetable(DEFAULT_MINER_GOAL_SPEC)).toBe(true); + expect(isMinerRepoTargetable({ ...DEFAULT_MINER_GOAL_SPEC, minerEnabled: false })).toBe(false); + }); + + it("returns 0 when a blocked label matches case-insensitively", () => { + const spec = { ...DEFAULT_MINER_GOAL_SPEC, blockedLabels: ["wontfix", "duplicate"] }; + expect(computeMinerGoalLaneFit({ labels: ["WontFix"] }, spec)).toBe(0); + expect(computeMinerGoalLaneFit({ labels: ["DUPLICATE"] }, spec)).toBe(0); + }); + + it("continues scoring when blocked labels are configured but none match", () => { + const spec = { ...DEFAULT_MINER_GOAL_SPEC, blockedLabels: ["wontfix"], preferredLabels: ["bug"] }; + expect(computeMinerGoalLaneFit({ labels: ["bug"] }, spec)).toBe(1); + expect(computeMinerGoalLaneFit({ labels: ["feature"] }, spec)).toBe(0.25); + }); + + it("scores normally when no blocked labels are configured", () => { + expect(computeMinerGoalLaneFit({ labels: ["docs"] }, DEFAULT_MINER_GOAL_SPEC)).toBe(1); + }); +}); diff --git a/test/unit/miner-goal-spec-parser.test.ts b/test/unit/miner-goal-spec-parser.test.ts index 1293270659..0acb3c6742 100644 --- a/test/unit/miner-goal-spec-parser.test.ts +++ b/test/unit/miner-goal-spec-parser.test.ts @@ -38,6 +38,7 @@ describe("MinerGoalSpec parser (#2301)", () => { wantedPaths: ["src/**", " src/** ", "", "docs/**"], blockedPaths: ["dist/**", longEntry], preferredLabels: ["help wanted", "help wanted", "gittensor:feature"], + blockedLabels: ["duplicate", " duplicate "], maxConcurrentClaims: 2.9, issueDiscoveryPolicy: "encouraged", }); @@ -49,6 +50,7 @@ describe("MinerGoalSpec parser (#2301)", () => { wantedPaths: ["src/**", "docs/**"], blockedPaths: ["dist/**", longEntry.slice(0, 256)], preferredLabels: ["help wanted", "gittensor:feature"], + blockedLabels: ["duplicate"], maxConcurrentClaims: 2, issueDiscoveryPolicy: "encouraged", }, @@ -82,6 +84,7 @@ describe("MinerGoalSpec parser (#2301)", () => { wantedPaths: "src/**", blockedPaths: [123, " dist/** "], preferredLabels: [false, "bugfix"], + blockedLabels: [123, " wontfix "], maxConcurrentClaims: "3", issueDiscoveryPolicy: "always", }); @@ -93,6 +96,7 @@ describe("MinerGoalSpec parser (#2301)", () => { wantedPaths: [], blockedPaths: ["dist/**"], preferredLabels: ["bugfix"], + blockedLabels: ["wontfix"], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", }, @@ -101,6 +105,7 @@ describe("MinerGoalSpec parser (#2301)", () => { expect.stringMatching(/wantedPaths/i), expect.stringMatching(/blockedPaths/i), expect.stringMatching(/preferredLabels/i), + expect.stringMatching(/blockedLabels/i), expect.stringMatching(/maxConcurrentClaims/i), expect.stringMatching(/issueDiscoveryPolicy/i), ]), @@ -131,6 +136,7 @@ describe("MinerGoalSpec parser (#2301)", () => { wantedPaths: [], blockedPaths: [], preferredLabels: [], + blockedLabels: [], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", }),