diff --git a/.gittensory.yml b/.gittensory.yml index b7e7f65171..9ed073180a 100644 --- a/.gittensory.yml +++ b/.gittensory.yml @@ -71,7 +71,11 @@ review: # fallback already has zero equivalent verification) so they propagate even when the PR author isn't a # formal GitHub assignee of the issue — our issues are almost always maintainer-authored for open pickup and # rarely formally assigned. priority intentionally omits the flag: it is the scarce, maintainer-hand-picked -# reward label, and must still require the PR author to be the issue's actual author/assignee. +# reward label, and must still require the PR author to be the issue's actual author/assignee. priority is +# also `removeOtherTypeLabels: false` (additive) -- unlike bug/feature, which are mutually-exclusive TYPE +# categories, priority is a separate reward dimension that coexists WITH whichever type already applies (an +# issue is routinely both gittensor:feature AND gittensor:priority at once); resolvePrTypeLabel composes every +# additive match alongside the one exclusive winner, rather than the two categories competing for a single slot. # # Review-evasion protection: closing or converting-to-draft your OWN PR while gittensory has an active # review pass running, a prior recorded gate failure, or a repeated ready<->draft cycle on this PR, is @@ -92,7 +96,7 @@ settings: trustMaintainerAuthoredIssue: true - issueLabel: "gittensor:priority" prLabel: "gittensor:priority" - removeOtherTypeLabels: true + removeOtherTypeLabels: false reviewEvasionProtection: close # Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 902ac43096..0d2e391a28 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -603,22 +603,24 @@ settings: # label like `gittensor:priority` -- it is NEVER inferred from a PR's title, changed files, AI # output, or existing PR labels, only ever copied from a linked/closing issue ("Fixes #123") that # ALREADY carries the configured issue label. Generic beyond the priority use case: any issue label - # can map to any PR label. `removeOtherTypeLabels: true` REPLACES the type label entirely (bug/ - # feature are removed), matching how `gittensor:priority` behaves today; `false` applies the mapped - # label ADDITIVELY alongside the normal title-based bug/feature label, leaving it untouched -- useful - # for a mapping unrelated to the bug/feature/priority triad (e.g. a `customer:vip` issue label - # copied to a `triage:vip` PR label). Disabled by default (no mappings) -- a self-hoster opts in per - # repo. If your labels carry reward or moderation weight, configure this in PRIVATE per-repo/global - # config (see `config/examples/README.md`) rather than the public `.gittensory.yml`, so contributors - # cannot see the exact mapping rules. A per-repo `mappings` override in the private-config layer - # REPLACES the global default list wholesale -- it does not merge with it. + # can map to any PR label. `removeOtherTypeLabels: true` marks the mapping EXCLUSIVE -- it REPLACES + # the type label entirely (bug/feature are removed), for genuinely mutually-exclusive categories; only + # the FIRST-configured exclusive match wins when more than one applies. `false` (e.g. `gittensor:priority`, + # which is a reward tag that coexists WITH whichever type already applies, not a type of its own) applies + # the mapped label ADDITIVELY alongside whichever exclusive match (or the normal title-based bug/feature + # label) already won -- every additive match composes together rather than competing for a single slot. + # Disabled by default (no mappings) -- a self-hoster opts in per repo. If your labels carry reward or + # moderation weight, configure this in PRIVATE per-repo/global config (see `config/examples/README.md`) + # rather than the public `.gittensory.yml`, so contributors cannot see the exact mapping rules. A per-repo + # `mappings` override in the private-config layer REPLACES the global default list wholesale -- it does + # not merge with it. # linkedIssueLabelPropagation: # enabled: true # mode: exclusive_type_label # mappings: # - issueLabel: gittensor:priority # prLabel: gittensor:priority - # removeOtherTypeLabels: true + # removeOtherTypeLabels: false # Create the label if it does not yet exist. Bool. Default: true. createMissingLabel: true diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 6b1fe56bc5..0a3f9c4126 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -616,22 +616,24 @@ settings: # label like `gittensor:priority` -- it is NEVER inferred from a PR's title, changed files, AI # output, or existing PR labels, only ever copied from a linked/closing issue ("Fixes #123") that # ALREADY carries the configured issue label. Generic beyond the priority use case: any issue label - # can map to any PR label. `removeOtherTypeLabels: true` REPLACES the type label entirely (bug/ - # feature are removed), matching how `gittensor:priority` behaves today; `false` applies the mapped - # label ADDITIVELY alongside the normal title-based bug/feature label, leaving it untouched -- useful - # for a mapping unrelated to the bug/feature/priority triad (e.g. a `customer:vip` issue label - # copied to a `triage:vip` PR label). Disabled by default (no mappings) -- a self-hoster opts in per - # repo. If your labels carry reward or moderation weight, configure this in PRIVATE per-repo/global - # config (see `config/examples/README.md`) rather than the public `.gittensory.yml`, so contributors - # cannot see the exact mapping rules. A per-repo `mappings` override in the private-config layer - # REPLACES the global default list wholesale -- it does not merge with it. + # can map to any PR label. `removeOtherTypeLabels: true` marks the mapping EXCLUSIVE -- it REPLACES + # the type label entirely (bug/feature are removed), for genuinely mutually-exclusive categories; only + # the FIRST-configured exclusive match wins when more than one applies. `false` (e.g. `gittensor:priority`, + # which is a reward tag that coexists WITH whichever type already applies, not a type of its own) applies + # the mapped label ADDITIVELY alongside whichever exclusive match (or the normal title-based bug/feature + # label) already won -- every additive match composes together rather than competing for a single slot. + # Disabled by default (no mappings) -- a self-hoster opts in per repo. If your labels carry reward or + # moderation weight, configure this in PRIVATE per-repo/global config (see `config/examples/README.md`) + # rather than the public `.gittensory.yml`, so contributors cannot see the exact mapping rules. A per-repo + # `mappings` override in the private-config layer REPLACES the global default list wholesale -- it does + # not merge with it. # linkedIssueLabelPropagation: # enabled: true # mode: exclusive_type_label # mappings: # - issueLabel: gittensor:priority # prLabel: gittensor:priority - # removeOtherTypeLabels: true + # removeOtherTypeLabels: false # Create the label if it does not yet exist. Bool. Default: true. createMissingLabel: true diff --git a/src/config/gittensory-repo-focus-manifest.ts b/src/config/gittensory-repo-focus-manifest.ts index c13cbe9ea1..52df45d4ee 100644 --- a/src/config/gittensory-repo-focus-manifest.ts +++ b/src/config/gittensory-repo-focus-manifest.ts @@ -75,7 +75,11 @@ review: # fallback already has zero equivalent verification) so they propagate even when the PR author isn't a # formal GitHub assignee of the issue — our issues are almost always maintainer-authored for open pickup and # rarely formally assigned. priority intentionally omits the flag: it is the scarce, maintainer-hand-picked -# reward label, and must still require the PR author to be the issue's actual author/assignee. +# reward label, and must still require the PR author to be the issue's actual author/assignee. priority is +# also \`removeOtherTypeLabels: false\` (additive) -- unlike bug/feature, which are mutually-exclusive TYPE +# categories, priority is a separate reward dimension that coexists WITH whichever type already applies (an +# issue is routinely both gittensor:feature AND gittensor:priority at once); resolvePrTypeLabel composes every +# additive match alongside the one exclusive winner, rather than the two categories competing for a single slot. # # Review-evasion protection: closing or converting-to-draft your OWN PR while gittensory has an active # review pass running, a prior recorded gate failure, or a repeated ready<->draft cycle on this PR, is @@ -96,7 +100,7 @@ settings: trustMaintainerAuthoredIssue: true - issueLabel: "gittensor:priority" prLabel: "gittensor:priority" - removeOtherTypeLabels: true + removeOtherTypeLabels: false reviewEvasionProtection: close # Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a diff --git a/src/settings/pr-type-label.ts b/src/settings/pr-type-label.ts index e905266c96..43a0b015ee 100644 --- a/src/settings/pr-type-label.ts +++ b/src/settings/pr-type-label.ts @@ -14,7 +14,7 @@ // moves away from it. Public + neutral categorization (NOT the reputation signal). Review-time + // independent of the gate / autonomy / dry-run (matches reviewbot, where auto-label runs at review // start). Fail-safe. -import type { LinkedIssueLabelPropagationConfig, PrTypeLabelSet } from "../types"; +import type { LinkedIssueLabelPropagationConfig, LinkedIssueLabelPropagationMapping, PrTypeLabelSet } from "../types"; export type { PrTypeLabelSet } from "../types"; @@ -156,9 +156,25 @@ export function resolvePrTypeLabel(input: { if (input.propagation?.enabled) { const wanted = new Set((input.linkedIssueLabels ?? []).map((label) => label.toLowerCase())); + // Collect EVERY mapping the linked issue's labels satisfy, not just the first. An exclusive mapping + // (removeOtherTypeLabels: true -- e.g. bug/feature, genuinely mutually-exclusive categories) still only + // ever lets the FIRST-configured match win, same precedence as before. But an additive mapping (e.g. + // priority -- a maintainer-hand-picked reward tag that coexists WITH whichever type already applies, not a + // type of its own) must compose with that winner instead of being skipped just because an earlier mapping + // in the array already matched and returned. Before this, an additive match was unreachable whenever the + // SAME linked issue also carried a label an earlier (exclusive) mapping matched -- the overwhelmingly common + // case for gittensor:priority, which is applied ALONGSIDE gittensor:bug/gittensor:feature on the issue, never + // instead of it (#priority-linked-issue-gate). + let exclusiveMatch: LinkedIssueLabelPropagationMapping | undefined; + const additiveMatches: LinkedIssueLabelPropagationMapping[] = []; for (const mapping of input.propagation.mappings) { if (!wanted.has(mapping.issueLabel.toLowerCase())) continue; - return mapping.removeOtherTypeLabels ? decide([mapping.prLabel], "propagation_exclusive") : decide([titleLabel, mapping.prLabel], "propagation_additive"); + if (mapping.removeOtherTypeLabels) exclusiveMatch ??= mapping; + else additiveMatches.push(mapping); + } + if (exclusiveMatch || additiveMatches.length > 0) { + const applyLabels = [exclusiveMatch ? exclusiveMatch.prLabel : titleLabel, ...additiveMatches.map((mapping) => mapping.prLabel)]; + return decide(applyLabels, exclusiveMatch ? "propagation_exclusive" : "propagation_additive"); } } return decide([titleLabel], "title"); diff --git a/test/unit/pr-type-label.test.ts b/test/unit/pr-type-label.test.ts index 975c062f4f..5f27b6223e 100644 --- a/test/unit/pr-type-label.test.ts +++ b/test/unit/pr-type-label.test.ts @@ -123,6 +123,66 @@ describe("resolvePrTypeLabel (#priority-linked-issue-gate)", () => { expect(result.source).toBe("propagation_exclusive"); }); + describe("additive matches compose with the exclusive winner (#priority-linked-issue-gate composition fix)", () => { + const bugFeaturePriorityMappings = [ + { issueLabel: "gittensor:bug", prLabel: "gittensor:bug", removeOtherTypeLabels: true }, + { issueLabel: "gittensor:feature", prLabel: "gittensor:feature", removeOtherTypeLabels: true }, + { issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false }, + ]; + + it("REGRESSION: a linked issue carrying BOTH gittensor:feature (exclusive) and gittensor:priority (additive) gets both labels, not just the first match", () => { + // Before this fix, the loop returned on the FIRST wanted mapping (feature, since it's checked before + // priority) and never even looked at priority's own mapping -- silently dropping priority whenever the + // SAME issue also carried a type label, which is the overwhelmingly common case in practice. + const result = resolvePrTypeLabel({ + title: "fix: y", + linkedIssueLabels: ["gittensor:feature", "gittensor:priority"], + propagation: propagation({ mappings: bugFeaturePriorityMappings }), + }); + expect(result).toEqual({ applyLabels: ["gittensor:feature", "gittensor:priority"], removeLabels: ["gittensor:bug"], source: "propagation_exclusive" }); + }); + + it("REGRESSION: a linked issue carrying BOTH gittensor:bug (exclusive) and gittensor:priority (additive) gets both labels", () => { + const result = resolvePrTypeLabel({ + title: "feat: add provider fallback", // title alone would say "feature" -- propagation must still win + linkedIssueLabels: ["gittensor:bug", "gittensor:priority"], + propagation: propagation({ mappings: bugFeaturePriorityMappings }), + }); + expect(result).toEqual({ applyLabels: ["gittensor:bug", "gittensor:priority"], removeLabels: ["gittensor:feature"], source: "propagation_exclusive" }); + }); + + it("priority alone (no bug/feature match): applies additively alongside the title-based label, source is propagation_additive", () => { + const result = resolvePrTypeLabel({ + title: "fix: y", + linkedIssueLabels: ["gittensor:priority"], + propagation: propagation({ mappings: bugFeaturePriorityMappings }), + }); + expect(result).toEqual({ applyLabels: ["gittensor:bug", "gittensor:priority"], removeLabels: ["gittensor:feature"], source: "propagation_additive" }); + }); + + it("multiple additive matches all compose together alongside a single exclusive winner", () => { + const result = resolvePrTypeLabel({ + title: "fix: y", + linkedIssueLabels: ["gittensor:bug", "gittensor:priority", "customer:vip"], + propagation: propagation({ + mappings: [...bugFeaturePriorityMappings, { issueLabel: "customer:vip", prLabel: "triage:vip", removeOtherTypeLabels: false }], + }), + }); + expect(result.applyLabels).toEqual(["gittensor:bug", "gittensor:priority", "triage:vip"]); + expect(result.source).toBe("propagation_exclusive"); + }); + + it("two exclusive candidates + an additive one: only the FIRST-configured exclusive mapping wins, additive still composes", () => { + const result = resolvePrTypeLabel({ + title: "fix: y", + linkedIssueLabels: ["gittensor:bug", "gittensor:feature", "gittensor:priority"], + propagation: propagation({ mappings: bugFeaturePriorityMappings }), + }); + // bug is configured before feature, so bug wins the exclusive slot even though both matched. + expect(result).toEqual({ applyLabels: ["gittensor:bug", "gittensor:priority"], removeLabels: ["gittensor:feature"], source: "propagation_exclusive" }); + }); + }); + it("respects a custom typeLabels set for both the title fallback and the removal set", () => { const custom = { bug: "kind:bug", feature: "kind:feature", priority: "kind:priority" }; const result = resolvePrTypeLabel({ title: "feat: add provider fallback", labels: custom }); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 28c860d7a8..9a3f3049d5 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -26673,9 +26673,15 @@ describe("queue processors", () => { }, }); + // JSONbored/gittensory falls back to its own bundled manifest (GITTENSORY_REPO_FOCUS_MANIFEST_YAML) when + // no other manifest source responds, which REPLACES this test's DB-configured single-mapping override + // with its own bug/feature (exclusive) + priority (additive) mapping list -- so the linked issue's + // gittensor:priority label composes with the title-derived "fix" -> gittensor:bug, rather than replacing + // it. Priority is additive (not a type of its own; see resolvePrTypeLabel's composition fix), so bug + // still applies from the title and only feature (never matched) needs removing. expect(seen.issueFetches).toBe(1); - expect(seen.posted).toEqual(["gittensor:priority"]); - expect(seen.removed.sort()).toEqual(["gittensor:bug", "gittensor:feature"]); + expect(seen.posted).toEqual(["gittensor:bug", "gittensor:priority"]); + expect(seen.removed).toEqual(["gittensor:feature"]); }); it("fails open to the normal title-based label when the linked issue's fetch fails (#priority-linked-issue-gate)", async () => {