From 304e88c7c209fcba7ce9fc35d8804d3dd339d7c7 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:44:49 -0700 Subject: [PATCH] fix(review): let a reward mapping opt into maintainer-authored-issue trust gittensor:priority propagation required the PR author to be the linked issue's own author or a GitHub assignee of it, with no relaxation -- unlike bug/feature, which already trust a maintainer-authored issue without formal assignment (trustMaintainerAuthoredIssue). But GitHub silently refuses to assign a contributor lacking push/triage access to the repo, and our issues are almost always maintainer-authored for open pickup, rarely formally assigned to a specific person. That combination meant the reward label could structurally never reach the external contributors it exists to reward, no matter how correctly the rest of the propagation pipeline behaved -- confirmed live on metagraphed PR #4554 (issue #3947 carried both gittensor:bug and gittensor:priority; only bug ever propagated). Adds a new, distinctly-named, opt-in LinkedIssueLabelPropagationMapping flag, trustMaintainerAuthoredIssueForReward, so a repo whose priority label is meant to incentivize open pickup (the maintainer's hand-picking already happened when the issue was labeled, not gated on which contributor later closes it) can extend the SAME maintainer-authored-issue relaxation to its reward mapping, while a repo that wants the strict author-or-assignee bar preserved for its reward label keeps that behavior unchanged by default. Enabled it for gittensor:priority in this repo's own config, matching the enrichment-issue open-pickup policy this label already implements for metagraphed. --- .gittensory.yml | 31 ++++--- .gittensory.yml.example | 8 ++ apps/gittensory-ui/public/openapi.json | 3 + config/examples/gittensory.full.yml | 8 ++ .../review/linked-issue-label-propagation.ts | 12 ++- .../src/types/manifest-deps-types.ts | 4 + src/config/gittensory-repo-focus-manifest.ts | 31 ++++--- src/openapi/schemas.ts | 1 + .../linked-issue-label-propagation-fetch.ts | 21 +++-- src/review/linked-issue-label-propagation.ts | 13 ++- src/types.ts | 14 +++ ...ked-issue-label-propagation-engine.test.ts | 27 ++++++ ...nked-issue-label-propagation-fetch.test.ts | 89 +++++++++++++++++++ .../linked-issue-label-propagation.test.ts | 27 ++++++ 14 files changed, 254 insertions(+), 35 deletions(-) diff --git a/.gittensory.yml b/.gittensory.yml index 9ed073180a..149046a4c0 100644 --- a/.gittensory.yml +++ b/.gittensory.yml @@ -64,18 +64,24 @@ review: skip_drafts: true skip_docs_only: true -# Linked-issue label propagation (#priority-linked-issue-gate, #priority-linked-issue-gate-ownership): a PR -# that closes/fixes/resolves an issue inherits that issue's point-bearing gittensor:* label onto the PR -# itself, instead of the PR's own label being decided purely by its commit-title prefix. bug/feature are -# `trustMaintainerAuthoredIssue: true` (routine categorization, no reward at stake, and the title-based -# 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. 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. +# Linked-issue label propagation (#priority-linked-issue-gate, #priority-linked-issue-gate-ownership, +# #priority-reward-maintainer-trust): a PR that closes/fixes/resolves an issue inherits that issue's +# point-bearing gittensor:* label onto the PR itself, instead of the PR's own label being decided purely by +# its commit-title prefix. bug/feature are `trustMaintainerAuthoredIssue: true` (routine categorization, no +# reward at stake, and the title-based 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 is a scarce, maintainer-hand- +# picked reward label, so it defaults to the strict author-or-assignee-only bar everywhere in this codebase +# EXCEPT here, where we explicitly opt it into the SAME relaxation via `trustMaintainerAuthoredIssueForReward: +# true`: our issues are open-pickup by design (see the flag's own doc comment in types.ts), so requiring a +# literal GitHub assignee relationship -- which GitHub silently refuses for a contributor lacking push/triage +# access -- meant this label could structurally never reach the external contributors it exists to reward. The +# maintainer's hand-picking already happened when the issue was labeled `gittensor:priority`, not gated on +# which contributor later closes it. 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 @@ -97,6 +103,7 @@ settings: - issueLabel: "gittensor:priority" prLabel: "gittensor:priority" removeOtherTypeLabels: false + trustMaintainerAuthoredIssueForReward: true 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 0d2e391a28..94a29b0497 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -609,6 +609,13 @@ settings: # 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. + # Each mapping normally requires the PR author to be the linked issue's own author or a GitHub assignee of + # it (`trustMaintainerAuthoredIssue: true` relaxes this for a routine, non-reward category like bug/feature + # -- see the field's own doc comment in `types.ts`). `trustMaintainerAuthoredIssueForReward: true` opts a + # REWARD mapping like `gittensor:priority` into that same relaxation: without it, a mapping like this can + # structurally never propagate to most external contributors, since GitHub silently refuses to assign + # anyone lacking push/triage access to the repo -- so if your issues are open-pickup (maintainer-authored, + # rarely formally assigned to a specific contributor), the reward label needs this flag to ever reach them. # 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 @@ -621,6 +628,7 @@ settings: # - issueLabel: gittensor:priority # prLabel: gittensor:priority # removeOtherTypeLabels: false + # trustMaintainerAuthoredIssueForReward: true # Create the label if it does not yet exist. Bool. Default: true. createMissingLabel: true diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index c561404223..c5e537058b 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8949,6 +8949,9 @@ }, "trustMaintainerAuthoredIssue": { "type": "boolean" + }, + "trustMaintainerAuthoredIssueForReward": { + "type": "boolean" } }, "required": [ diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 0a3f9c4126..41d6450699 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -622,6 +622,13 @@ settings: # 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. + # Each mapping normally requires the PR author to be the linked issue's own author or a GitHub assignee of + # it (`trustMaintainerAuthoredIssue: true` relaxes this for a routine, non-reward category like bug/feature + # -- see the field's own doc comment in `types.ts`). `trustMaintainerAuthoredIssueForReward: true` opts a + # REWARD mapping like `gittensor:priority` into that same relaxation: without it, a mapping like this can + # structurally never propagate to most external contributors, since GitHub silently refuses to assign + # anyone lacking push/triage access to the repo -- so if your issues are open-pickup (maintainer-authored, + # rarely formally assigned to a specific contributor), the reward label needs this flag to ever reach them. # 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 @@ -634,6 +641,7 @@ settings: # - issueLabel: gittensor:priority # prLabel: gittensor:priority # removeOtherTypeLabels: false + # trustMaintainerAuthoredIssueForReward: true # Create the label if it does not yet exist. Bool. Default: true. createMissingLabel: true diff --git a/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts b/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts index d80c4bc284..4ce3da28b3 100644 --- a/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts +++ b/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts @@ -60,7 +60,17 @@ function normalizeMapping(input: unknown, index: number, warnings: string[]): Li warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].trustMaintainerAuthoredIssue must be a boolean; ignoring it.`); } } - return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true, trustMaintainerAuthoredIssue }; + // Same parse contract as trustMaintainerAuthoredIssue just above (#priority-reward-maintainer-trust). + // Mirrors `src/review/linked-issue-label-propagation.ts`'s copy of this normalizer. + let trustMaintainerAuthoredIssueForReward: boolean | undefined; + if (record.trustMaintainerAuthoredIssueForReward !== undefined) { + if (typeof record.trustMaintainerAuthoredIssueForReward === "boolean") { + trustMaintainerAuthoredIssueForReward = record.trustMaintainerAuthoredIssueForReward; + } else { + warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].trustMaintainerAuthoredIssueForReward must be a boolean; ignoring it.`); + } + } + return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true, trustMaintainerAuthoredIssue, trustMaintainerAuthoredIssueForReward }; } /** Defaults-fill a per-repo `linkedIssueLabelPropagation` override into an always-complete, safe config — diff --git a/packages/gittensory-engine/src/types/manifest-deps-types.ts b/packages/gittensory-engine/src/types/manifest-deps-types.ts index 82e19ec2fd..292d5a4771 100644 --- a/packages/gittensory-engine/src/types/manifest-deps-types.ts +++ b/packages/gittensory-engine/src/types/manifest-deps-types.ts @@ -50,6 +50,10 @@ export type LinkedIssueLabelPropagationMapping = { * see `review/linked-issue-label-propagation-fetch.ts`'s `isRepoMaintainerLogin` (app-side only, not * duplicated into this engine package since it needs GitHub/fetch/Env access). */ trustMaintainerAuthoredIssue?: boolean | undefined; + /** Like `trustMaintainerAuthoredIssue`, but for a mapping that DOES carry real reward weight + * (#priority-reward-maintainer-trust) -- e.g. `gittensor:priority`. Mirrors `src/types.ts`'s copy of + * this type; see that copy's doc comment for the full rationale. */ + trustMaintainerAuthoredIssueForReward?: boolean | undefined; }; export type LinkedIssueLabelPropagationMode = "exclusive_type_label"; diff --git a/src/config/gittensory-repo-focus-manifest.ts b/src/config/gittensory-repo-focus-manifest.ts index 52df45d4ee..30fd6caa1f 100644 --- a/src/config/gittensory-repo-focus-manifest.ts +++ b/src/config/gittensory-repo-focus-manifest.ts @@ -68,18 +68,24 @@ review: skip_drafts: true skip_docs_only: true -# Linked-issue label propagation (#priority-linked-issue-gate, #priority-linked-issue-gate-ownership): a PR -# that closes/fixes/resolves an issue inherits that issue's point-bearing gittensor:* label onto the PR -# itself, instead of the PR's own label being decided purely by its commit-title prefix. bug/feature are -# \`trustMaintainerAuthoredIssue: true\` (routine categorization, no reward at stake, and the title-based -# 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. 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. +# Linked-issue label propagation (#priority-linked-issue-gate, #priority-linked-issue-gate-ownership, +# #priority-reward-maintainer-trust): a PR that closes/fixes/resolves an issue inherits that issue's +# point-bearing gittensor:* label onto the PR itself, instead of the PR's own label being decided purely by +# its commit-title prefix. bug/feature are \`trustMaintainerAuthoredIssue: true\` (routine categorization, no +# reward at stake, and the title-based 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 is a scarce, maintainer-hand- +# picked reward label, so it defaults to the strict author-or-assignee-only bar everywhere in this codebase +# EXCEPT here, where we explicitly opt it into the SAME relaxation via \`trustMaintainerAuthoredIssueForReward: +# true\`: our issues are open-pickup by design (see the flag's own doc comment in types.ts), so requiring a +# literal GitHub assignee relationship -- which GitHub silently refuses for a contributor lacking push/triage +# access -- meant this label could structurally never reach the external contributors it exists to reward. The +# maintainer's hand-picking already happened when the issue was labeled \`gittensor:priority\`, not gated on +# which contributor later closes it. 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 @@ -101,6 +107,7 @@ settings: - issueLabel: "gittensor:priority" prLabel: "gittensor:priority" removeOtherTypeLabels: false + trustMaintainerAuthoredIssueForReward: true reviewEvasionProtection: close # Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 31f28bb38a..7dca0848e1 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -691,6 +691,7 @@ export const RepositorySettingsSchema = z prLabel: z.string(), removeOtherTypeLabels: z.boolean(), trustMaintainerAuthoredIssue: z.boolean().optional(), + trustMaintainerAuthoredIssueForReward: z.boolean().optional(), }), ), }) diff --git a/src/review/linked-issue-label-propagation-fetch.ts b/src/review/linked-issue-label-propagation-fetch.ts index 03f6b3faa9..1c17dbc90d 100644 --- a/src/review/linked-issue-label-propagation-fetch.ts +++ b/src/review/linked-issue-label-propagation-fetch.ts @@ -42,12 +42,15 @@ async function isRepoMaintainerLogin(env: Env, installationId: number, repoFullN /** Per-issue label resolution for {@link fetchLinkedIssueLabelsForPropagation}: a direct PR-author-is- * issue-author-or-assignee match unlocks EVERY label the issue carries (today's original behavior, - * unchanged). Failing that, a mapping explicitly opted into `trustMaintainerAuthoredIssue` - * (#priority-linked-issue-gate-ownership) unlocks JUST that mapping's `issueLabel` when the issue's - * author independently checks out as a repo maintainer/operator via {@link isRepoMaintainerLogin} -- - * built so routine bug/feature mirroring doesn't require formal GitHub issue assignment (our own repos - * rarely assign issues), while a scarce, maintainer-hand-picked reward label like `gittensor:priority` - * (which should never set the flag) still requires the contributor to be the actual author/assignee. + * unchanged). Failing that, a mapping explicitly opted into `trustMaintainerAuthoredIssue` OR + * `trustMaintainerAuthoredIssueForReward` (#priority-linked-issue-gate-ownership, #priority-reward- + * maintainer-trust) unlocks JUST that mapping's `issueLabel` when the issue's author independently + * checks out as a repo maintainer/operator via {@link isRepoMaintainerLogin} -- built so routine + * bug/feature mirroring doesn't require formal GitHub issue assignment (our own repos rarely assign + * issues). A reward mapping (e.g. `gittensor:priority`) opting into the SAME relaxation via the + * `...ForReward` flag is a deliberate, per-repo operator choice (see that flag's own doc comment in + * types.ts for why the assignee-only bar is often unsatisfiable in practice); a reward mapping that has + * NOT opted in still requires the contributor to be the actual author/assignee, unchanged. * `relaxableLabels` is empty whenever the caller passed no mappings or none opted in, which skips the * maintainer-permission check (and its GitHub API call) entirely -- byte-identical to the pre-fix * behavior for any caller that hasn't opted in. Logs once per issue when the returned set is smaller @@ -104,8 +107,8 @@ async function resolveIssueLabelsForPropagation( * * `mappings` (optional, #priority-linked-issue-gate-ownership) is the propagation config's own mapping * list, used ONLY to know which `issueLabel`s are allowed to unlock via `resolveIssueLabelsForPropagation`'s - * relaxed maintainer-authored-issue path -- omitting it (or a mapping never setting the flag) reproduces - * today's strict author-or-assignee-only behavior exactly. */ + * relaxed maintainer-authored-issue path (either trust flag) -- omitting it (or a mapping never setting + * either flag) reproduces today's strict author-or-assignee-only behavior exactly. */ export async function fetchLinkedIssueLabelsForPropagation(args: { env: Env; repoFullName: string; @@ -128,7 +131,7 @@ export async function fetchLinkedIssueLabelsForPropagation(args: { const prAuthorLogin = args.prAuthorLogin?.toLowerCase(); const relaxableLabels = new Set( (args.mappings ?? []) - .filter((mapping) => mapping.trustMaintainerAuthoredIssue === true) + .filter((mapping) => mapping.trustMaintainerAuthoredIssue === true || mapping.trustMaintainerAuthoredIssueForReward === true) .map((mapping) => mapping.issueLabel.toLowerCase()), ); const results = await Promise.all( diff --git a/src/review/linked-issue-label-propagation.ts b/src/review/linked-issue-label-propagation.ts index ded3a9111d..10118fdbfa 100644 --- a/src/review/linked-issue-label-propagation.ts +++ b/src/review/linked-issue-label-propagation.ts @@ -60,7 +60,18 @@ function normalizeMapping(input: unknown, index: number, warnings: string[]): Li warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].trustMaintainerAuthoredIssue must be a boolean; ignoring it.`); } } - return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true, trustMaintainerAuthoredIssue }; + // Same parse contract as trustMaintainerAuthoredIssue just above (#priority-reward-maintainer-trust): + // malformed is warned-and-defaulted to undefined/strict, never silently coerced, never a reason to drop + // an otherwise-valid mapping. + let trustMaintainerAuthoredIssueForReward: boolean | undefined; + if (record.trustMaintainerAuthoredIssueForReward !== undefined) { + if (typeof record.trustMaintainerAuthoredIssueForReward === "boolean") { + trustMaintainerAuthoredIssueForReward = record.trustMaintainerAuthoredIssueForReward; + } else { + warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].trustMaintainerAuthoredIssueForReward must be a boolean; ignoring it.`); + } + } + return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true, trustMaintainerAuthoredIssue, trustMaintainerAuthoredIssueForReward }; } /** Defaults-fill a per-repo `linkedIssueLabelPropagation` override into an always-complete, safe config — diff --git a/src/types.ts b/src/types.ts index d4c0ff1ca1..70d41af18d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1137,6 +1137,20 @@ export type LinkedIssueLabelPropagationMapping = { * scarce, hand-picked label a contributor could otherwise farm by citing an unrelated issue they had no * part in. See `review/linked-issue-label-propagation-fetch.ts`'s `isRepoMaintainerLogin`. */ trustMaintainerAuthoredIssue?: boolean | undefined; + /** Like `trustMaintainerAuthoredIssue`, but for a mapping that DOES carry real reward weight (#priority- + * linked-issue-gate-ownership, #priority-reward-maintainer-trust) -- e.g. `gittensor:priority`. Deliberately + * a SEPARATE, distinctly-named flag rather than reusing `trustMaintainerAuthoredIssue`, so a repo that wants + * the strict author-or-assignee bar preserved for its reward label keeps that behavior by default; this must + * be explicitly opted into. GitHub silently refuses to assign a contributor lacking push/triage access to the + * repo (`ensurePullRequestAssignee`'s own doc comment) -- so for a repo whose issues are opened for open + * pickup and rarely formally assigned, requiring a literal GitHub assignee relationship means the reward + * label can structurally never propagate to most real external contributors, no matter how the assign action + * is timed. This flag accepts the SAME evidence bug/feature already trust (a maintainer authored the linked + * issue) as sufficient for the reward label too, when a repo's operator has decided that's the intended + * workflow (e.g. a maintainer hand-labels an issue `gittensor:priority` specifically to attract ANY + * contributor to pick it up, per the label's own "reserved for outstanding work" framing -- the hand-picking + * already happened at issue-labeling time, not gated on which contributor later closes it). */ + trustMaintainerAuthoredIssueForReward?: boolean | undefined; }; export type LinkedIssueLabelPropagationMode = "exclusive_type_label"; diff --git a/test/unit/linked-issue-label-propagation-engine.test.ts b/test/unit/linked-issue-label-propagation-engine.test.ts index d53e57a823..0eeef0d06e 100644 --- a/test/unit/linked-issue-label-propagation-engine.test.ts +++ b/test/unit/linked-issue-label-propagation-engine.test.ts @@ -131,6 +131,33 @@ describe("normalizeLinkedIssueLabelPropagationConfig (#priority-linked-issue-gat expect(warnings.some((w) => w.includes("mappings[0].trustMaintainerAuthoredIssue"))).toBe(true); }); + it("passes through a mapping's trustMaintainerAuthoredIssueForReward: true unchanged (#priority-reward-maintainer-trust)", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig( + { enabled: true, mappings: [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false, trustMaintainerAuthoredIssueForReward: true }] }, + warnings, + ); + expect(result.mappings).toEqual([{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false, trustMaintainerAuthoredIssueForReward: true }]); + expect(warnings).toEqual([]); + }); + + it("leaves trustMaintainerAuthoredIssueForReward undefined (not defaulted to false) when omitted from a mapping, with no warning", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: true, mappings: [{ issueLabel: "a", prLabel: "b" }] }, warnings); + expect(result.mappings[0]?.trustMaintainerAuthoredIssueForReward).toBeUndefined(); + expect(warnings).toEqual([]); + }); + + it("warns and ignores a non-boolean trustMaintainerAuthoredIssueForReward, keeping the rest of the mapping (never silently defaults to true)", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig( + { enabled: true, mappings: [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", trustMaintainerAuthoredIssueForReward: "true" }] }, + warnings, + ); + expect(result.mappings).toEqual([{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false, trustMaintainerAuthoredIssueForReward: undefined }]); + expect(warnings.some((w) => w.includes("mappings[0].trustMaintainerAuthoredIssueForReward"))).toBe(true); + }); + it("defaults removeOtherTypeLabels to false when omitted from a mapping", () => { const warnings: string[] = []; const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: true, mappings: [{ issueLabel: "a", prLabel: "b" }] }, warnings); diff --git a/test/unit/linked-issue-label-propagation-fetch.test.ts b/test/unit/linked-issue-label-propagation-fetch.test.ts index e45e9c329b..2f181d2b4f 100644 --- a/test/unit/linked-issue-label-propagation-fetch.test.ts +++ b/test/unit/linked-issue-label-propagation-fetch.test.ts @@ -430,6 +430,95 @@ describe("fetchLinkedIssueLabelsForPropagation (#priority-linked-issue-gate)", ( expect(fetchSpy.mock.calls.some(([input]) => input.toString().includes("/collaborators/"))).toBe(false); }); + describe("reward-label maintainer trust (#priority-reward-maintainer-trust)", () => { + it("REGRESSION (metagraphed PR #4554 / issue #3947 shape): a reward mapping with trustMaintainerAuthoredIssueForReward propagates alongside a routine trusted label from the SAME maintainer-authored issue, for a contributor who is neither its author nor assignee", async () => { + const mappings = [ + { issueLabel: "gittensor:bug", prLabel: "gittensor:bug", removeOtherTypeLabels: true, trustMaintainerAuthoredIssue: true }, + { issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false, trustMaintainerAuthoredIssueForReward: true }, + ]; + stubFetch((url) => { + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/3947")) + return Response.json({ number: 3947, state: "open", user: { login: "owner" }, assignees: [], labels: ["gittensor:bug", "gittensor:priority"] }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({}); + const result = await fetchLinkedIssueLabelsForPropagation({ + env, + repoFullName: "owner/repo", + linkedIssues: [3947], + installationId: 123, + prAuthorLogin: "contrib", + mappings, + }); + expect(result.sort()).toEqual(["gittensor:bug", "gittensor:priority"]); + }); + + it("does NOT propagate the reward label from a maintainer-authored issue when its mapping has not opted into trustMaintainerAuthoredIssueForReward (unchanged strict default)", async () => { + const mappings = [ + { issueLabel: "gittensor:bug", prLabel: "gittensor:bug", removeOtherTypeLabels: true, trustMaintainerAuthoredIssue: true }, + { issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false }, + ]; + stubFetch((url) => { + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/3948")) + return Response.json({ number: 3948, state: "open", user: { login: "owner" }, assignees: [], labels: ["gittensor:bug", "gittensor:priority"] }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({}); + const result = await fetchLinkedIssueLabelsForPropagation({ + env, + repoFullName: "owner/repo", + linkedIssues: [3948], + installationId: 123, + prAuthorLogin: "contrib", + mappings, + }); + expect(result).toEqual(["gittensor:bug"]); + }); + + it("still propagates the reward label via trustMaintainerAuthoredIssueForReward when the issue is authored by an ADMIN_GITHUB_LOGINS fleet-operator", async () => { + const mappings = [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false, trustMaintainerAuthoredIssueForReward: true }]; + stubFetch((url) => { + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/3949")) + return Response.json({ number: 3949, state: "open", user: { login: "fleetop" }, assignees: [], labels: ["gittensor:priority"] }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "fleetop" }); + const result = await fetchLinkedIssueLabelsForPropagation({ + env, + repoFullName: "owner/repo", + linkedIssues: [3949], + installationId: 123, + prAuthorLogin: "contrib", + mappings, + }); + expect(result).toEqual(["gittensor:priority"]); + }); + + it("does not propagate the reward label when the issue author is only a read-access collaborator (fails closed, same as trustMaintainerAuthoredIssue)", async () => { + const mappings = [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false, trustMaintainerAuthoredIssueForReward: true }]; + stubFetch((url) => { + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/3950")) + return Response.json({ number: 3950, state: "open", user: { login: "rando" }, assignees: [], labels: ["gittensor:priority"] }); + if (url.includes("/collaborators/rando/permission")) return Response.json({ permission: "read" }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + const result = await fetchLinkedIssueLabelsForPropagation({ + env, + repoFullName: "owner/repo", + linkedIssues: [3950], + installationId: 123, + prAuthorLogin: "contrib", + mappings, + }); + expect(result).toEqual([]); + }); + }); + it("does not propagate anything when mappings are configured but none set trustMaintainerAuthoredIssue, even for the literal repo owner's own issue", async () => { stubFetch((url) => { if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); diff --git a/test/unit/linked-issue-label-propagation.test.ts b/test/unit/linked-issue-label-propagation.test.ts index ce9c4951a0..e3c20d4c65 100644 --- a/test/unit/linked-issue-label-propagation.test.ts +++ b/test/unit/linked-issue-label-propagation.test.ts @@ -116,6 +116,33 @@ describe("normalizeLinkedIssueLabelPropagationConfig (#priority-linked-issue-gat expect(warnings.some((w) => w.includes("mappings[0].trustMaintainerAuthoredIssue"))).toBe(true); }); + it("passes through a mapping's trustMaintainerAuthoredIssueForReward: true unchanged (#priority-reward-maintainer-trust)", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig( + { enabled: true, mappings: [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false, trustMaintainerAuthoredIssueForReward: true }] }, + warnings, + ); + expect(result.mappings).toEqual([{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false, trustMaintainerAuthoredIssueForReward: true }]); + expect(warnings).toEqual([]); + }); + + it("leaves trustMaintainerAuthoredIssueForReward undefined (not defaulted to false) when omitted from a mapping, with no warning", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: true, mappings: [{ issueLabel: "a", prLabel: "b" }] }, warnings); + expect(result.mappings[0]?.trustMaintainerAuthoredIssueForReward).toBeUndefined(); + expect(warnings).toEqual([]); + }); + + it("warns and ignores a non-boolean trustMaintainerAuthoredIssueForReward, keeping the rest of the mapping (never silently defaults to true)", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig( + { enabled: true, mappings: [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", trustMaintainerAuthoredIssueForReward: "true" }] }, + warnings, + ); + expect(result.mappings).toEqual([{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false, trustMaintainerAuthoredIssueForReward: undefined }]); + expect(warnings.some((w) => w.includes("mappings[0].trustMaintainerAuthoredIssueForReward"))).toBe(true); + }); + it("defaults removeOtherTypeLabels to false when omitted from a mapping", () => { const warnings: string[] = []; const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: true, mappings: [{ issueLabel: "a", prLabel: "b" }] }, warnings);