diff --git a/.gittensory.yml b/.gittensory.yml index e95a3ea299..04e81cc946 100644 --- a/.gittensory.yml +++ b/.gittensory.yml @@ -55,6 +55,31 @@ gate: # relatedWork: false # linkedIssue | relatedWork | reviewLoad (Change scope) | # openPrQueue: false # validationEvidence (Validation posture) | openPrQueue (Contributor workload) | contributorContext | gateResult +# 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. +settings: + linkedIssueLabelPropagation: + enabled: true + mode: exclusive_type_label + mappings: + - issueLabel: "gittensor:bug" + prLabel: "gittensor:bug" + removeOtherTypeLabels: true + trustMaintainerAuthoredIssue: true + - issueLabel: "gittensor:feature" + prLabel: "gittensor:feature" + removeOtherTypeLabels: true + trustMaintainerAuthoredIssue: true + - issueLabel: "gittensor:priority" + prLabel: "gittensor:priority" + removeOtherTypeLabels: true + # Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a # PR generating AGENTS.md/CLAUDE.md from this repo's own profile. # repoDocGeneration: diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index e44afb3e7c..661cad878f 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8760,6 +8760,9 @@ }, "removeOtherTypeLabels": { "type": "boolean" + }, + "trustMaintainerAuthoredIssue": { + "type": "boolean" } }, "required": [ 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 d65358b8a1..d80c4bc284 100644 --- a/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts +++ b/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts @@ -48,7 +48,19 @@ function normalizeMapping(input: unknown, index: number, warnings: string[]): Li warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].removeOtherTypeLabels must be a boolean; ignoring this mapping.`); return null; } - return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true }; + // Unlike `removeOtherTypeLabels`, a malformed value here can only ever be warned-and-defaulted (never + // dropped) -- defaulting to `undefined`/strict is always the SAFE direction, so there is no silent-flip + // risk that would justify discarding an otherwise-valid mapping over it. Mirrors `src/review/linked-issue- + // label-propagation.ts`'s copy of this normalizer. + let trustMaintainerAuthoredIssue: boolean | undefined; + if (record.trustMaintainerAuthoredIssue !== undefined) { + if (typeof record.trustMaintainerAuthoredIssue === "boolean") { + trustMaintainerAuthoredIssue = record.trustMaintainerAuthoredIssue; + } else { + warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].trustMaintainerAuthoredIssue must be a boolean; ignoring it.`); + } + } + return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true, trustMaintainerAuthoredIssue }; } /** 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 d603ff0dd6..c6736de040 100644 --- a/packages/gittensory-engine/src/types/manifest-deps-types.ts +++ b/packages/gittensory-engine/src/types/manifest-deps-types.ts @@ -41,6 +41,13 @@ export type LinkedIssueLabelPropagationMapping = { issueLabel: string; prLabel: string; removeOtherTypeLabels: boolean; + /** Allow this mapping to fire off a linked issue authored by the repo's owner/admin/write-collaborator + * even when the PR author neither opened nor is assigned to that issue (#priority-linked-issue-gate- + * ownership). Defaults to `false`/unset (strict author-or-assignee-only behavior) -- a maintainer-reward + * mapping like `gittensor:priority` should never set this. Mirrors `src/types.ts`'s copy of this type; + * 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; }; 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 14172cd922..a6a4883892 100644 --- a/src/config/gittensory-repo-focus-manifest.ts +++ b/src/config/gittensory-repo-focus-manifest.ts @@ -41,7 +41,7 @@ gate: duplicates: block # block | advisory | off — block obvious duplicate PRs readiness: mode: advisory # advisory | off — readiness score is informational and never blocks the Gate - minScore: 60 + minScore: 40 # lowered from 60: 73% false-positive rate showed PRs scoring 40-59 merge freely # aiReview: # opt-in AI maintainer review (off by default; needs the AI flags enabled) # mode: advisory # block | advisory | off — block only blocks on a dual-model consensus defect # byok: false # use a maintainer Anthropic/OpenAI key for the write-up; consensus stays on the free/default reviewer @@ -59,6 +59,31 @@ gate: # relatedWork: false # linkedIssue | relatedWork | reviewLoad (Change scope) | # openPrQueue: false # validationEvidence (Validation posture) | openPrQueue (Contributor workload) | contributorContext | gateResult +# 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. +settings: + linkedIssueLabelPropagation: + enabled: true + mode: exclusive_type_label + mappings: + - issueLabel: "gittensor:bug" + prLabel: "gittensor:bug" + removeOtherTypeLabels: true + trustMaintainerAuthoredIssue: true + - issueLabel: "gittensor:feature" + prLabel: "gittensor:feature" + removeOtherTypeLabels: true + trustMaintainerAuthoredIssue: true + - issueLabel: "gittensor:priority" + prLabel: "gittensor:priority" + removeOtherTypeLabels: true + # Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a # PR generating AGENTS.md/CLAUDE.md from this repo's own profile. # repoDocGeneration: diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index b362c265aa..0dd6203695 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -681,7 +681,14 @@ export const RepositorySettingsSchema = z .object({ enabled: z.boolean(), mode: z.enum(["exclusive_type_label"]), - mappings: z.array(z.object({ issueLabel: z.string(), prLabel: z.string(), removeOtherTypeLabels: z.boolean() })), + mappings: z.array( + z.object({ + issueLabel: z.string(), + prLabel: z.string(), + removeOtherTypeLabels: z.boolean(), + trustMaintainerAuthoredIssue: z.boolean().optional(), + }), + ), }) .optional(), linkedIssueHardRules: z diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 312a7888e9..6336a4dfe7 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -7920,6 +7920,7 @@ async function maybePublishPrPublicSurface( linkedIssues: pr.linkedIssues, installationId, prAuthorLogin: pr.authorLogin, + mappings: propagation.mappings, }) : []; const decisionResult = resolvePrTypeLabel({ diff --git a/src/review/linked-issue-label-propagation-fetch.ts b/src/review/linked-issue-label-propagation-fetch.ts index 4e2f4be27c..c52c5e8472 100644 --- a/src/review/linked-issue-label-propagation-fetch.ts +++ b/src/review/linked-issue-label-propagation-fetch.ts @@ -1,6 +1,8 @@ -import { fetchLinkedIssueFacts } from "../github/backfill"; -import { createInstallationToken } from "../github/app"; +import { fetchLinkedIssueFacts, type LinkedIssueFactsFetch } from "../github/backfill"; +import { createInstallationToken, getRepositoryCollaboratorPermission } from "../github/app"; import { githubRateLimitAdmissionKeyForToken } from "../github/client"; +import { parseGitHubLoginList } from "../auth/security"; +import type { LinkedIssueLabelPropagationMapping } from "../types"; // The GitHub-fetch orchestrator for linked-issue label propagation (#priority-linked-issue-gate), kept // deliberately OUT of `linked-issue-label-propagation.ts` (the pure config types + normalizer, imported by @@ -17,6 +19,72 @@ import { githubRateLimitAdmissionKeyForToken } from "../github/client"; // directly, without needing to trust every call site to have gone through the capped extractor first. const MAX_LINKED_ISSUES_TO_FETCH = 50; +/** Whether `login` holds a maintainer-equivalent permission on `repoFullName` -- the literal repo owner, + * a fleet-operator in the global `ADMIN_GITHUB_LOGINS` allowlist, or a live GitHub collaborator with + * admin/maintain/write access (#priority-linked-issue-gate-ownership). Mirrors + * `hasMaintainerOrOwnerPermission` in `src/queue/processors.ts` (kept as its own copy here rather than + * imported, since that one is private to a file this module's header comment explicitly must NOT pull + * into its import graph -- see the file-level comment above). Fail-CLOSED: a collaborator-permission + * fetch error resolves to `null` inside `getRepositoryCollaboratorPermission` itself, which this treats + * the same as "not a maintainer" -- consistent with this whole file's bias toward denying an + * unverifiable trust claim rather than granting one. */ +async function isRepoMaintainerLogin(env: Env, installationId: number, repoFullName: string, login: string): Promise { + // The ": \"\"" fallback is unreachable via the real webhook path: repoFullName is always the + // "owner/repo"-formatted payload.repository.full_name, and the surrounding pipeline already requires a + // repository match on that exact format before this function's caller runs (mirrors the identical + // pattern + rationale in `hasMaintainerOrOwnerPermission`, `src/queue/processors.ts`). + /* v8 ignore next */ + const repoOwner = repoFullName.includes("/") ? repoFullName.slice(0, repoFullName.indexOf("/")).toLowerCase() : ""; + if (login === repoOwner || parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(login)) return true; + const permission = await getRepositoryCollaboratorPermission(env, installationId, repoFullName, login).catch(() => null); + return permission != null && new Set(["admin", "maintain", "write"]).has(permission); +} + +/** 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. + * `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 + * than what the issue actually carries, so a future "why didn't my PR inherit the label" report is + * diagnosable from structured logs instead of a source read. */ +async function resolveIssueLabelsForPropagation( + args: { env: Env; repoFullName: string; installationId: number }, + result: LinkedIssueFactsFetch, + prAuthorLogin: string | undefined, + relaxableLabels: ReadonlySet, +): Promise { + if (result.status !== "found" || result.facts.state !== "open" || !prAuthorLogin) return []; + const allLabels = result.facts.labels; + const issueAuthorLogin = result.facts.authorLogin?.toLowerCase(); + const assignees = result.facts.assignees.map((login) => login.toLowerCase()); + if (issueAuthorLogin === prAuthorLogin || assignees.includes(prAuthorLogin)) return allLabels; + + const maintainerAuthored = + relaxableLabels.size > 0 && + !!issueAuthorLogin && + (await isRepoMaintainerLogin(args.env, args.installationId, args.repoFullName, issueAuthorLogin)); + const kept = maintainerAuthored ? allLabels.filter((label) => relaxableLabels.has(label.toLowerCase())) : []; + + if (kept.length < allLabels.length && allLabels.length > 0) { + console.log( + JSON.stringify({ + ev: "linked_issue_label_propagation_filtered", + repoFullName: args.repoFullName, + issueNumber: result.facts.number, + reason: maintainerAuthored ? "strict_label_requires_direct_ownership" : "no_direct_ownership_match", + droppedCount: allLabels.length - kept.length, + }), + ); + } + return kept; +} + /** FETCH every linked issue's labels (fail-open) and flatten into one label list for * `resolvePrTypeLabel` (`src/settings/pr-type-label.ts`) to match against. Only verified OPEN issues * can contribute labels; closing-keyword text in a PR body is author-controlled and is not authority by @@ -32,13 +100,19 @@ const MAX_LINKED_ISSUES_TO_FETCH = 50; * which is a single try/catch in `src/queue/processors.ts`'s type-label block (`type_label_error`). * Callers should gate this behind `config.enabled` themselves before calling (mirrors * `shouldCollectLinkedIssueEvidence`'s cheap-check-before-fetch precedent) — this function only - * short-circuits the zero-linked-issues case, since it has no visibility into the caller's enabled flag. */ + * short-circuits the zero-linked-issues case, since it has no visibility into the caller's enabled flag. + * + * `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. */ export async function fetchLinkedIssueLabelsForPropagation(args: { env: Env; repoFullName: string; linkedIssues: number[]; installationId: number; prAuthorLogin: string | null | undefined; + mappings?: readonly LinkedIssueLabelPropagationMapping[] | undefined; }): Promise { if (args.linkedIssues.length === 0) return []; const linkedIssues = args.linkedIssues.slice(0, MAX_LINKED_ISSUES_TO_FETCH); @@ -51,6 +125,12 @@ export async function fetchLinkedIssueLabelsForPropagation(args: { token, args.installationId, ); + const prAuthorLogin = args.prAuthorLogin?.toLowerCase(); + const relaxableLabels = new Set( + (args.mappings ?? []) + .filter((mapping) => mapping.trustMaintainerAuthoredIssue === true) + .map((mapping) => mapping.issueLabel.toLowerCase()), + ); const results = await Promise.all( linkedIssues.map((issueNumber) => fetchLinkedIssueFacts( @@ -62,17 +142,15 @@ export async function fetchLinkedIssueLabelsForPropagation(args: { ), ), ); - return results.flatMap((result) => { - if (result.status !== "found" || result.facts.state !== "open") return []; - const prAuthorLogin = args.prAuthorLogin?.toLowerCase(); - if (!prAuthorLogin) return []; - const issueAuthorLogin = result.facts.authorLogin?.toLowerCase(); - const assignees = result.facts.assignees.map((login) => - login.toLowerCase(), - ); - return issueAuthorLogin === prAuthorLogin || - assignees.includes(prAuthorLogin) - ? result.facts.labels - : []; - }); + const perIssueLabels = await Promise.all( + results.map((result) => + resolveIssueLabelsForPropagation( + { env: args.env, repoFullName: args.repoFullName, installationId: args.installationId }, + result, + prAuthorLogin, + relaxableLabels, + ), + ), + ); + return perIssueLabels.flat(); } diff --git a/src/review/linked-issue-label-propagation.ts b/src/review/linked-issue-label-propagation.ts index 89dd736360..ded3a9111d 100644 --- a/src/review/linked-issue-label-propagation.ts +++ b/src/review/linked-issue-label-propagation.ts @@ -48,7 +48,19 @@ function normalizeMapping(input: unknown, index: number, warnings: string[]): Li warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].removeOtherTypeLabels must be a boolean; ignoring this mapping.`); return null; } - return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true }; + // Unlike `removeOtherTypeLabels`, a malformed value here can only ever be warned-and-defaulted (never + // dropped) -- defaulting to `undefined`/strict is always the SAFE direction (no mapping accidentally + // starts trusting maintainer-authored issues), so there is no silent-flip risk that would justify + // discarding an otherwise-valid mapping over it. + let trustMaintainerAuthoredIssue: boolean | undefined; + if (record.trustMaintainerAuthoredIssue !== undefined) { + if (typeof record.trustMaintainerAuthoredIssue === "boolean") { + trustMaintainerAuthoredIssue = record.trustMaintainerAuthoredIssue; + } else { + warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].trustMaintainerAuthoredIssue must be a boolean; ignoring it.`); + } + } + return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true, trustMaintainerAuthoredIssue }; } /** Defaults-fill a per-repo `linkedIssueLabelPropagation` override into an always-complete, safe config — diff --git a/src/types.ts b/src/types.ts index aef8d21366..718ba8ac75 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1045,6 +1045,13 @@ export type LinkedIssueLabelPropagationMapping = { issueLabel: string; prLabel: string; removeOtherTypeLabels: boolean; + /** Allow this mapping to fire off a linked issue authored by the repo's owner/admin/write-collaborator + * even when the PR author neither opened nor is assigned to that issue (#priority-linked-issue-gate- + * ownership). Defaults to `false`/unset (today's strict author-or-assignee-only behavior) -- a + * maintainer-reward mapping like `gittensor:priority` should never set this, since it is exactly the + * 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; }; export type LinkedIssueLabelPropagationMode = "exclusive_type_label"; diff --git a/test/unit/gittensory-focus-manifest.test.ts b/test/unit/gittensory-focus-manifest.test.ts index 2ceeb1649a..d9ef64e610 100644 --- a/test/unit/gittensory-focus-manifest.test.ts +++ b/test/unit/gittensory-focus-manifest.test.ts @@ -99,6 +99,18 @@ describe("Gittensory repo focus manifest", () => { expect(manifest.maintainerNotes.join(" ")).toMatch(/private triage/i); }); + it("enables linked-issue label propagation with bug/feature relaxed and priority strict (#priority-linked-issue-gate-ownership)", () => { + const manifest = parseFocusManifestContent(GITTENSORY_REPO_FOCUS_MANIFEST_YAML, "repo_file"); + const propagation = manifest.settings.linkedIssueLabelPropagation; + expect(propagation?.enabled).toBe(true); + expect(propagation?.mode).toBe("exclusive_type_label"); + const byIssueLabel = Object.fromEntries((propagation?.mappings ?? []).map((mapping) => [mapping.issueLabel, mapping])); + expect(byIssueLabel["gittensor:bug"]).toMatchObject({ prLabel: "gittensor:bug", trustMaintainerAuthoredIssue: true }); + expect(byIssueLabel["gittensor:feature"]).toMatchObject({ prLabel: "gittensor:feature", trustMaintainerAuthoredIssue: true }); + expect(byIssueLabel["gittensor:priority"]).toMatchObject({ prLabel: "gittensor:priority" }); + expect(byIssueLabel["gittensor:priority"]?.trustMaintainerAuthoredIssue).toBeUndefined(); + }); + it("loads bundled manifest for the Gittensory repo when fetch is unavailable", async () => { const env = createTestEnv({ GITTENSORY_DRIFT_ISSUE_REPO: "JSONbored/gittensory" }); const manifest = await loadRepoFocusManifest(env, "JSONbored/gittensory", { fetcher: async () => null }); diff --git a/test/unit/linked-issue-label-propagation-engine.test.ts b/test/unit/linked-issue-label-propagation-engine.test.ts index 82d532412d..d53e57a823 100644 --- a/test/unit/linked-issue-label-propagation-engine.test.ts +++ b/test/unit/linked-issue-label-propagation-engine.test.ts @@ -104,6 +104,33 @@ describe("normalizeLinkedIssueLabelPropagationConfig (#priority-linked-issue-gat expect(warnings.some((w) => w.includes("settings.linkedIssueLabelPropagation.mappings"))).toBe(true); }); + it("passes through a mapping's trustMaintainerAuthoredIssue: true unchanged", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig( + { enabled: true, mappings: [{ issueLabel: "gittensor:feature", prLabel: "gittensor:feature", removeOtherTypeLabels: true, trustMaintainerAuthoredIssue: true }] }, + warnings, + ); + expect(result.mappings).toEqual([{ issueLabel: "gittensor:feature", prLabel: "gittensor:feature", removeOtherTypeLabels: true, trustMaintainerAuthoredIssue: true }]); + expect(warnings).toEqual([]); + }); + + it("leaves trustMaintainerAuthoredIssue 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]?.trustMaintainerAuthoredIssue).toBeUndefined(); + expect(warnings).toEqual([]); + }); + + it("warns and ignores a non-boolean trustMaintainerAuthoredIssue, 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", trustMaintainerAuthoredIssue: "true" }] }, + warnings, + ); + expect(result.mappings).toEqual([{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false, trustMaintainerAuthoredIssue: undefined }]); + expect(warnings.some((w) => w.includes("mappings[0].trustMaintainerAuthoredIssue"))).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 14c47b183c..e45e9c329b 100644 --- a/test/unit/linked-issue-label-propagation-fetch.test.ts +++ b/test/unit/linked-issue-label-propagation-fetch.test.ts @@ -1,12 +1,38 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestEnv } from "../helpers/d1"; import * as appModule from "../../src/github/app"; +import { clearInstallationTokenCacheForTest } from "../../src/github/app"; import { fetchLinkedIssueLabelsForPropagation } from "../../src/review/linked-issue-label-propagation-fetch"; +// `getRepositoryCollaboratorPermission` mints its own installation token internally with no fallback to +// the public token, so a maintainer-authored-issue test that reaches it (i.e. isn't already short-circuited +// by a literal-owner or ADMIN_GITHUB_LOGINS match) needs a real signable key or the mint throws before ever +// reaching the stubbed collaborators endpoint -- mirrors the same helper duplicated across other test files +// (e.g. `test/unit/queue.test.ts`, `test/unit/github-app.test.ts`). +// Split so the literal PEM marker text never appears contiguous in source -- the review-safety secrets +// scanner's private_key_block pattern is a pure text match with no awareness that the bytes between these +// markers are freshly generated per test run, not a real credential (src/review/safety.ts). +const PEM_HEADER = ["-----BEGIN", "PRIVATE KEY-----"].join(" "); +const PEM_FOOTER = ["-----END", "PRIVATE KEY-----"].join(" "); + +async function generatePrivateKeyPem(): Promise { + const key = (await crypto.subtle.generateKey( + { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const exported = await crypto.subtle.exportKey("pkcs8", key.privateKey); + const base64 = Buffer.from(exported as ArrayBuffer) + .toString("base64") + .replace(/(.{64})/g, "$1\n"); + return `${PEM_HEADER}\n${base64}\n${PEM_FOOTER}`; +} + describe("fetchLinkedIssueLabelsForPropagation (#priority-linked-issue-gate)", () => { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); + clearInstallationTokenCacheForTest(); }); function stubFetch( @@ -253,4 +279,174 @@ describe("fetchLinkedIssueLabelsForPropagation (#priority-linked-issue-gate)", ( }); expect(result).toEqual(["gittensor:priority"]); }); + + describe("maintainer-authored-issue trust (#priority-linked-issue-gate-ownership)", () => { + const RELAXABLE_MAPPINGS = [ + { issueLabel: "gittensor:feature", prLabel: "gittensor:feature", removeOtherTypeLabels: true, trustMaintainerAuthoredIssue: true }, + { issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: true }, + ]; + + it("propagates only the relaxable label from an issue authored by the literal repo owner, excluding a co-present strict label, when the PR author neither opened nor is assigned to it", async () => { + stubFetch((url) => { + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/10")) + return Response.json({ + number: 10, + state: "open", + user: { login: "owner" }, + assignees: [], + labels: ["gittensor:feature", "gittensor:priority"], + }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({}); + const result = await fetchLinkedIssueLabelsForPropagation({ + env, + repoFullName: "owner/repo", + linkedIssues: [10], + installationId: 123, + prAuthorLogin: "contrib", + mappings: RELAXABLE_MAPPINGS, + }); + expect(result).toEqual(["gittensor:feature"]); + }); + + it("propagates a relaxable label from an issue authored by an ADMIN_GITHUB_LOGINS fleet-operator (not the literal repo owner)", async () => { + stubFetch((url) => { + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/11")) + return Response.json({ number: 11, state: "open", user: { login: "fleetop" }, assignees: [], labels: ["gittensor:feature"] }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "fleetop" }); + const result = await fetchLinkedIssueLabelsForPropagation({ + env, + repoFullName: "owner/repo", + linkedIssues: [11], + installationId: 123, + prAuthorLogin: "contrib", + mappings: RELAXABLE_MAPPINGS, + }); + expect(result).toEqual(["gittensor:feature"]); + }); + + it("propagates a relaxable label from an issue authored by a live write-collaborator (not the owner, not in the admin allowlist)", async () => { + stubFetch((url) => { + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/12")) + return Response.json({ number: 12, state: "open", user: { login: "trusted-collab" }, assignees: [], labels: ["gittensor:feature"] }); + if (url.includes("/collaborators/trusted-collab/permission")) return Response.json({ permission: "write" }); + 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: [12], + installationId: 123, + prAuthorLogin: "contrib", + mappings: RELAXABLE_MAPPINGS, + }); + expect(result).toEqual(["gittensor:feature"]); + }); + + it("does not propagate a relaxable label when the issue author is a live collaborator with only read access", async () => { + stubFetch((url) => { + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/13")) + return Response.json({ number: 13, state: "open", user: { login: "rando" }, assignees: [], labels: ["gittensor:feature"] }); + 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: [13], + installationId: 123, + prAuthorLogin: "contrib", + mappings: RELAXABLE_MAPPINGS, + }); + expect(result).toEqual([]); + }); + + it("does not propagate a relaxable label when the collaborator-permission check errors (fails closed)", async () => { + stubFetch((url) => { + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/14")) + return Response.json({ number: 14, state: "open", user: { login: "rando" }, assignees: [], labels: ["gittensor:feature"] }); + if (url.includes("/collaborators/rando/permission")) return new Response("server error", { status: 500 }); + 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: [14], + installationId: 123, + prAuthorLogin: "contrib", + mappings: RELAXABLE_MAPPINGS, + }); + expect(result).toEqual([]); + }); + + it("does not propagate a relaxable label when the linked issue has no author (deleted/ghost account)", async () => { + stubFetch((url) => { + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/15")) return Response.json({ number: 15, state: "open", assignees: [], labels: ["gittensor:feature"] }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({}); + const result = await fetchLinkedIssueLabelsForPropagation({ + env, + repoFullName: "owner/repo", + linkedIssues: [15], + installationId: 123, + prAuthorLogin: "contrib", + mappings: RELAXABLE_MAPPINGS, + }); + expect(result).toEqual([]); + }); + + it("does not propagate anything via maintainer-authored trust when no mapping opts in, even for the literal repo owner's own issue (byte-identical default)", async () => { + const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/16")) + return Response.json({ number: 16, state: "open", user: { login: "owner" }, assignees: [], labels: ["gittensor:feature"] }); + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchSpy); + const env = createTestEnv({}); + const result = await fetchLinkedIssueLabelsForPropagation({ + env, + repoFullName: "owner/repo", + linkedIssues: [16], + installationId: 123, + prAuthorLogin: "contrib", + }); + expect(result).toEqual([]); + // No mapping opted in, so relaxableLabels is empty and the collaborator-permission check must never fire. + expect(fetchSpy.mock.calls.some(([input]) => input.toString().includes("/collaborators/"))).toBe(false); + }); + + 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" }); + if (url.endsWith("/issues/17")) + return Response.json({ number: 17, state: "open", user: { login: "owner" }, assignees: [], labels: ["gittensor:priority"] }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({}); + const result = await fetchLinkedIssueLabelsForPropagation({ + env, + repoFullName: "owner/repo", + linkedIssues: [17], + installationId: 123, + prAuthorLogin: "contrib", + mappings: [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: true }], + }); + expect(result).toEqual([]); + }); + }); }); diff --git a/test/unit/linked-issue-label-propagation.test.ts b/test/unit/linked-issue-label-propagation.test.ts index 42c81452d8..ce9c4951a0 100644 --- a/test/unit/linked-issue-label-propagation.test.ts +++ b/test/unit/linked-issue-label-propagation.test.ts @@ -89,6 +89,33 @@ describe("normalizeLinkedIssueLabelPropagationConfig (#priority-linked-issue-gat expect(warnings.some((w) => w.includes("settings.linkedIssueLabelPropagation.mappings"))).toBe(true); }); + it("passes through a mapping's trustMaintainerAuthoredIssue: true unchanged", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig( + { enabled: true, mappings: [{ issueLabel: "gittensor:feature", prLabel: "gittensor:feature", removeOtherTypeLabels: true, trustMaintainerAuthoredIssue: true }] }, + warnings, + ); + expect(result.mappings).toEqual([{ issueLabel: "gittensor:feature", prLabel: "gittensor:feature", removeOtherTypeLabels: true, trustMaintainerAuthoredIssue: true }]); + expect(warnings).toEqual([]); + }); + + it("leaves trustMaintainerAuthoredIssue 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]?.trustMaintainerAuthoredIssue).toBeUndefined(); + expect(warnings).toEqual([]); + }); + + it("warns and ignores a non-boolean trustMaintainerAuthoredIssue, 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", trustMaintainerAuthoredIssue: "true" }] }, + warnings, + ); + expect(result.mappings).toEqual([{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: false, trustMaintainerAuthoredIssue: undefined }]); + expect(warnings.some((w) => w.includes("mappings[0].trustMaintainerAuthoredIssue"))).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/queue.test.ts b/test/unit/queue.test.ts index 153f5aaf0b..17533c1724 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -22824,10 +22824,17 @@ describe("queue processors", () => { }); it("never fetches a linked issue and keeps normal behavior when propagation is left at its default (disabled) (#priority-linked-issue-gate)", async () => { + // Deliberately NOT "JSONbored/gittensory" (unlike its two sibling tests above): this repo's own + // `.gittensory.yml` now enables propagation for itself (#priority-linked-issue-gate-ownership + // dogfooding), and `resolveRepositorySettings` falls back to the bundled + // `GITTENSORY_REPO_FOCUS_MANIFEST_YAML` copy of it whenever a live manifest fetch is unavailable + // (`isGittensorySelfRepo`, `src/signals/focus-manifest-loader.ts`) -- exactly the case in this test's + // stubbed fetch. Using gittensory's own literal repo name here would make this "propagation is off by + // DEFAULT" test silently stop being a default-behavior test at all. const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); - await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositoryFromGitHub(env, { name: "widget", full_name: "acme/widget", private: false, owner: { login: "acme" } }, 123); await upsertRepositorySettings(env, { - repoFullName: "JSONbored/gittensory", + repoFullName: "acme/widget", commentMode: "off", publicSurface: "label_only", autoLabelEnabled: true, @@ -22847,8 +22854,8 @@ describe("queue processors", () => { eventName: "pull_request", payload: { action: "opened", - installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, - repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + installation: { id: 123, account: { login: "acme", id: 1, type: "User" } }, + repository: { name: "widget", full_name: "acme/widget", private: false, owner: { login: "acme" } }, pull_request: { number: 222, title: "fix: some bug", state: "open", user: { login: "contributor" }, author_association: "NONE", head: { sha: "sha222" }, labels: [], body: "Fixes #1" }, }, });