From 199d8cca2311e79552194ff48414e0fd5dea4529 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:54:45 -0700 Subject: [PATCH] fix(selfhost): decouple taxonomy labels from outcome labels The per-PR TYPE label (gittensor:bug/feature/priority) was nested inside the public-surface label decision, so miner-detection status, oss_maintainer mode, or a maintainer-authored PR could silently suppress it even though it is internal triage metadata, not a contributor-facing signal. Add an independent typeLabelsEnabled setting (default true) and apply the type label regardless of author type or the narrower reasons the base context label can be off, while still respecting publicAudienceMode: gittensor_only's stricter promise of total silence for a non-confirmed-miner author and the agentPaused kill-switch. --- .gittensory.yml.example | 20 ++ apps/gittensory-ui/public/openapi.json | 8 + migrations/0101_type_labels_enabled.sql | 9 + src/db/repositories.ts | 5 + src/db/schema.ts | 3 + src/openapi/schemas.ts | 2 + src/queue/processors.ts | 148 ++++++++---- src/signals/focus-manifest.ts | 3 +- src/signals/settings-preview.ts | 2 + src/types.ts | 12 + test/unit/focus-manifest.test.ts | 14 ++ test/unit/queue.test.ts | 308 ++++++++++++++++++++++++ 12 files changed, 483 insertions(+), 51 deletions(-) create mode 100644 migrations/0101_type_labels_enabled.sql diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 4be51ad4fa..942d37e90d 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -282,6 +282,15 @@ settings: # The label name to apply. String. Default: gittensor. gittensorLabel: gittensor + # Apply the per-PR TYPE/taxonomy label -- exactly one of gittensor:bug / gittensor:feature / + # gittensor:priority, classified from the PR title + changed paths (#label-decoupling). Independent of + # `autoLabelEnabled` above (which only governs the base `gittensorLabel` context label): a type label is + # internal triage metadata, so it applies to every PR regardless of author type (bot/maintainer/missing + # author) or `publicAudienceMode`/`publicSurface`, UNLESS `publicAudienceMode: gittensor_only` is muting + # this PR's author entirely (that mode's whole point is total silence for a non-confirmed-miner author). + # Bool. Default: true. + typeLabelsEnabled: true + # Create the label if it does not yet exist. Bool. Default: true. createMissingLabel: true @@ -311,6 +320,17 @@ settings: # Bool. Default: false. agentDryRun: false + # Four independent label families, none of which gates or silently disables another (#label-decoupling, + # #label-scoping): + # 1. Context label (`gittensorLabel`, gated by `autoLabelEnabled` above) — the base per-PR marker shown + # to the public surface, subject to `publicAudienceMode`/`publicSurface`/author-qualification. + # 2. Taxonomy/type label (`gittensor:bug`/`gittensor:feature`/`gittensor:priority`, gated by + # `typeLabelsEnabled` above) — internal triage metadata, applied independently of #1. + # 3. Autonomy-outcome labels (`gittensory:ready-to-merge` etc.) — gated by the `review_state_label` + # autonomy class below, advisory commentary on the bot's own verdict. + # 4. Anti-abuse enforcement labels (blacklist/contributor-cap/review-nag) — gated by the `close` + # autonomy class below, applied alongside the close action they accompany. + # # Autonomy dial — per-action-class level (observe … auto). Classes: review, request_changes, approve, # merge, close, label, review_state_label, update_branch. Map. Default: {} (= observe everywhere, # deny-by-default). diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index fb60fd61f6..6557476b30 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8966,6 +8966,9 @@ "contributorCapCancelCi": { "type": "boolean", "nullable": true + }, + "typeLabelsEnabled": { + "type": "boolean" } }, "required": [ @@ -8991,6 +8994,7 @@ "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", + "typeLabelsEnabled", "gittensorLabel", "blacklistLabel", "createMissingLabel", @@ -9624,6 +9628,9 @@ "defaultAllowed", "commandOverrides" ] + }, + "typeLabelsEnabled": { + "type": "boolean" } }, "required": [ @@ -9644,6 +9651,7 @@ "selfAuthoredLinkedIssueGateMode", "firstTimeContributorGrace", "autoLabelEnabled", + "typeLabelsEnabled", "gittensorLabel", "blacklistLabel", "createMissingLabel", diff --git a/migrations/0101_type_labels_enabled.sql b/migrations/0101_type_labels_enabled.sql new file mode 100644 index 0000000000..94584a447c --- /dev/null +++ b/migrations/0101_type_labels_enabled.sql @@ -0,0 +1,9 @@ +-- Label decoupling (#label-decoupling): the per-PR TYPE label (gittensor:bug/feature/priority, +-- classified from the PR title + changed paths) was previously gated by autoLabelEnabled nested +-- inside decidePublicSurface's public-contributor-surface decision -- so miner-detection status, +-- publicAudienceMode, or a maintainer-authored PR could silently suppress it, even though type +-- labels are internal triage metadata meant to apply unconditionally. This column independently +-- controls the type label, separate from autoLabelEnabled (the base gittensor context label). +-- Default true matches the prior de-facto behavior (autoLabelEnabled defaults true too), so +-- existing repos see no behavior change until they explicitly opt out. +ALTER TABLE repository_settings ADD COLUMN type_labels_enabled INTEGER NOT NULL DEFAULT 1; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 0b32fcb904..3dec574f06 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -488,6 +488,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise aiReviewAllAuthors: false, closeOwnerAuthors: false, autoLabelEnabled: true, + typeLabelsEnabled: true, gittensorLabel: "gittensor", blacklistLabel: "slop", createMissingLabel: true, @@ -549,6 +550,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise aiReviewAllAuthors: row.aiReviewAllAuthors, closeOwnerAuthors: row.closeOwnerAuthors, autoLabelEnabled: row.autoLabelEnabled, + typeLabelsEnabled: row.typeLabelsEnabled, gittensorLabel: row.gittensorLabel, blacklistLabel: row.blacklistLabel, createMissingLabel: row.createMissingLabel, @@ -646,6 +648,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial 0 + ? await resolvePullRequestFilesForReview(env, { + installationId, + repoFullName, + pullNumber: pr.number, + }).catch(() => [] as Awaited>) + : []; + /* v8 ignore stop */ + const chosenType = resolvePrTypeLabel({ + title: pr.title, + /* v8 ignore next -- see the contentGlobs note above; typeFiles is always [] today so this callback never runs */ + changedPaths: typeFiles.map((file) => file.path), + contentGlobs, + }); + await ensurePullRequestLabel( + env, + installationId, + repoFullName, + pr.number, + chosenType, + { createMissingLabel: true, mode }, + ); + for (const other of ALL_TYPE_LABELS.filter( + (label) => label !== chosenType, + )) { + await removePullRequestLabel( + env, + installationId, + repoFullName, + pr.number, + other, + mode, + ); + } + console.log( + JSON.stringify({ + ev: "type_label_decision", + repoFullName, + pull: pr.number, + applied: true, + label: chosenType, + }), + ); + } catch (error) { + console.log( + JSON.stringify({ + ev: "type_label_error", + repoFullName, + pull: pr.number, + message: errorMessage(error).slice(0, 150), + }), + ); + } + } else { + console.log( + JSON.stringify({ + ev: "type_label_decision", + repoFullName, + pull: pr.number, + applied: false, + reason: settings.agentPaused + ? "agent_paused" + : decision.skipReason === "miner_detection_unavailable" || + decision.skipReason === "not_official_gittensor_miner" + ? decision.skipReason + : "typeLabelsEnabled_false", + }), + ); + } + // Respect the per-repo agent pause: suppress all public surface mutations (label, comment, context // check run) so a paused repo sees no gittensory-authored GitHub content. The review-agent check // run still posts so the required-check status is not broken (#agent-pause). @@ -7853,56 +7951,6 @@ async function maybePublishPrPublicSurface( ); if (isGitHubRateLimitedError(error)) throw error; } - // Per-PR TYPE label (reviewbot auto-label parity): exactly ONE of gittensor:bug/feature/priority by the PR - // title + changed paths. Review-time + neutral, BEST-EFFORT + independent of the context label above so a - // type-label hiccup never drops the "label" output. Files are only fetched when content globs are configured - // (otherwise the label is title-derived). The status labels (ready-to-merge etc.) remain the autonomy layer's. - if (settings.autoLabelEnabled) { - try { - const contentGlobs = - (settings as { contentGlobs?: string[] }).contentGlobs ?? []; - const typeFiles = - contentGlobs.length > 0 - ? await getReviewFiles().catch( - () => [] as Awaited>, - ) - : []; - const chosenType = resolvePrTypeLabel({ - title: pr.title, - changedPaths: typeFiles.map((file) => file.path), - contentGlobs, - }); - await ensurePullRequestLabel( - env, - installationId, - repoFullName, - pr.number, - chosenType, - { createMissingLabel: true, mode }, - ); - for (const other of ALL_TYPE_LABELS.filter( - (label) => label !== chosenType, - )) { - await removePullRequestLabel( - env, - installationId, - repoFullName, - pr.number, - other, - mode, - ); - } - } catch (error) { - console.log( - JSON.stringify({ - ev: "type_label_error", - repoFullName, - pull: pr.number, - message: errorMessage(error).slice(0, 150), - }), - ); - } - } } return finishPublicSurfacePublication(); } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 96e2a80827..e066273372 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -158,6 +158,7 @@ export type FocusManifestSettings = Partial< | "aiReviewAllAuthors" | "closeOwnerAuthors" | "autoLabelEnabled" + | "typeLabelsEnabled" | "badgeEnabled" | "gittensorLabel" | "createMissingLabel" @@ -940,7 +941,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) } const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"] as const, warnings); if (publicSurface !== null) out.publicSurface = publicSurface; - for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "badgeEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { + for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 1fdc4088da..265dc1d0a5 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -195,6 +195,7 @@ export type RepoSettingsPreview = { firstTimeContributorGrace: boolean; slopGateMinScore?: number | null | undefined; autoLabelEnabled: boolean; + typeLabelsEnabled: boolean; gittensorLabel: string; blacklistLabel: string; createMissingLabel: boolean; @@ -320,6 +321,7 @@ export function buildRepoSettingsPreview(args: { firstTimeContributorGrace: settings.firstTimeContributorGrace, slopGateMinScore: settings.slopGateMinScore ?? null, autoLabelEnabled: settings.autoLabelEnabled, + typeLabelsEnabled: settings.typeLabelsEnabled ?? true, gittensorLabel: settings.gittensorLabel, blacklistLabel: settings.blacklistLabel ?? "slop", createMissingLabel: settings.createMissingLabel, diff --git a/src/types.ts b/src/types.ts index ccce3f3e49..2ad8c52690 100644 --- a/src/types.ts +++ b/src/types.ts @@ -672,6 +672,18 @@ export type RepositorySettings = { autoLabelEnabled: boolean; gittensorLabel: string; createMissingLabel: boolean; + /** #label-decoupling: independently gates the per-PR TYPE/taxonomy label (exactly one of + * `gittensor:bug`/`gittensor:feature`/`gittensor:priority`, classified from the PR title + + * changed paths — see `resolvePrTypeLabel` in `settings/pr-type-label.ts`). Distinct from + * {@link autoLabelEnabled} (which governs only the base {@link gittensorLabel} context label) and + * from `decidePublicSurface`'s public-surface gate (miner detection / `publicAudienceMode` / + * `includeMaintainerAuthors` / bot-author exclusion) — type labels are internal triage metadata + * applied unconditionally to every PR, not a contributor-facing signal, so neither of those + * public-surface conditions should suppress them. Default TRUE (matches the prior de-facto + * behavior before this field existed, when type labels were gated by `autoLabelEnabled` nested + * inside the public-surface check). Always populated by the DB layer; optional so existing + * settings fixtures/callers need not be touched. */ + typeLabelsEnabled?: boolean | undefined; publicSurface: "off" | "comment_and_label" | "comment_only" | "label_only"; includeMaintainerAuthors: boolean; requireLinkedIssue: boolean; diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 776a2ec1e2..4c93854a80 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -1617,6 +1617,20 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(eff.badgeEnabled).toBe(true); // settings: override wins over the DB-stored value }); + it("wires settings.typeLabelsEnabled into the manifest parser and lets a per-repo override win over a global default (#label-decoupling)", () => { + const parsedTrue = parseFocusManifest({ settings: { typeLabelsEnabled: true } }); + expect(parsedTrue.settings.typeLabelsEnabled).toBe(true); + expect(parsedTrue.warnings).toEqual([]); + const parsedFalse = parseFocusManifest({ settings: { typeLabelsEnabled: false } }); + expect(parsedFalse.settings.typeLabelsEnabled).toBe(false); + + // Simulates PR #1's private-config layering: a global default of `true` (DB, standing in for the + // global .gittensory.yml layer already merged upstream) overridden by a per-repo `settings:` block. + const db = { typeLabelsEnabled: true } as unknown as RepositorySettings; + const eff = resolveEffectiveSettings(db, parseFocusManifest({ settings: { typeLabelsEnabled: false } })); + expect(eff.typeLabelsEnabled).toBe(false); // settings: override wins over the DB/global-default value + }); + it("parses aiReview from settings: and lets gate.aiReview win in resolveEffectiveSettings", () => { const parsed = parseFocusManifest({ settings: { aiReviewMode: "advisory", aiReviewByok: true } }); expect(parsed.settings.aiReviewMode).toBe("advisory"); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 75baf192e8..3ebcbd10c6 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -16189,6 +16189,314 @@ describe("queue processors", () => { expect(warn.mock.calls.map((c) => String(c[0])).some((line) => line.includes("ops_anomaly") && line.includes("owner/repo"))).toBe(true); warn.mockRestore(); }); + + describe("type label decoupling (#label-decoupling)", () => { + function stubTypeLabelFetch(prNumber: number, seen: { posted: string[]; removed: string[]; checkRunCreated: boolean }) { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes(`/commits/`) && url.includes("/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") { + seen.checkRunCreated = true; + return Response.json({ id: 9001 }, { status: 201 }); + } + if (url.includes("/check-runs/") && method === "PATCH") return Response.json({ id: 9001 }); + if (url.includes(`/issues/${prNumber}/labels`) && method === "GET") return Response.json([]); + if (url.includes(`/issues/${prNumber}/labels`) && method === "POST") { + seen.posted.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); + return Response.json([]); + } + if (url.includes(`/issues/${prNumber}/labels/`) && method === "DELETE") { + seen.removed.push(decodeURIComponent(url.split(`/issues/${prNumber}/labels/`)[1] ?? "")); + return new Response(null, { status: 204 }); + } + if (url.endsWith("/labels") && method === "POST") return Response.json({ name: JSON.parse(String(init?.body ?? "{}")).name }, { status: 201 }); + if (url.includes(`/issues/${prNumber}/comments`) && method === "GET") return Response.json([]); + if (url.includes(`/issues/${prNumber}/comments`) && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + return new Response("not found", { status: 404 }); + }); + } + + it("applies the type label when oss_maintainer mode + an unconfirmed miner suppress the context label", async () => { + 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 upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "label_only", + publicAudienceMode: "oss_maintainer", + autoLabelEnabled: true, + createMissingLabel: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "not_found" }, 60_000); + const seen = { posted: [] as string[], removed: [] as string[], checkRunCreated: false }; + stubTypeLabelFetch(210, seen); + + await processJob(env, { + type: "github-webhook", + deliveryId: "type-label-oss-maintainer-unconfirmed", + 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" } }, + pull_request: { number: 210, title: "fix: broken pagination", state: "open", user: { login: "contributor" }, author_association: "NONE", head: { sha: "sha210" }, labels: [], body: "Fixes #1" }, + }, + }); + + expect(seen.posted).toEqual(["gittensor:bug"]); + expect(seen.removed.sort()).toEqual(["gittensor:feature", "gittensor:priority"]); + }); + + it("still mutes the type label when gittensor_only mode's non-confirmed-miner silence applies, even with the gate enabled", async () => { + 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 upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_and_label", + publicAudienceMode: "gittensor_only", + autoLabelEnabled: true, + createMissingLabel: false, + checkRunMode: "off", + // The only difference from the pre-existing "keeps GitHub-history-only contributors quiet" test + // (which has the gate off, so it returns before ever reaching the type-label decision): with the + // gate ENABLED, the function does NOT bail out early, so this is the only path that actually + // exercises `decision.skipReason === "not_official_gittensor_miner"` at the type-label gate. + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "not_found" }, 60_000); + const seen = { posted: [] as string[], removed: [] as string[], checkRunCreated: false }; + stubTypeLabelFetch(217, seen); + + await processJob(env, { + type: "github-webhook", + deliveryId: "type-label-gittensor-only-muted", + 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" } }, + pull_request: { number: 217, title: "fix: gittensor_only silence", state: "open", user: { login: "contributor" }, author_association: "NONE", head: { sha: "sha217" }, labels: [], body: "Fixes #1" }, + }, + }); + + expect(seen.posted).toEqual([]); + expect(seen.removed).toEqual([]); + }); + + it("does not apply the type label when typeLabelsEnabled is false, in the same oss_maintainer + unconfirmed-miner scenario", async () => { + 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 upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "label_only", + publicAudienceMode: "oss_maintainer", + autoLabelEnabled: true, + typeLabelsEnabled: false, + createMissingLabel: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "not_found" }, 60_000); + const seen = { posted: [] as string[], removed: [] as string[], checkRunCreated: false }; + stubTypeLabelFetch(211, seen); + + await processJob(env, { + type: "github-webhook", + deliveryId: "type-label-disabled-oss-maintainer-unconfirmed", + 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" } }, + pull_request: { number: 211, title: "fix: broken pagination", state: "open", user: { login: "contributor" }, author_association: "NONE", head: { sha: "sha211" }, labels: [], body: "Fixes #1" }, + }, + }); + + expect(seen.posted).toEqual([]); + expect(seen.removed).toEqual([]); + }); + + it("applies the type label to a maintainer-authored PR even though includeMaintainerAuthors excludes it from the public surface", async () => { + 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 upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "label_only", + autoLabelEnabled: true, + includeMaintainerAuthors: false, + createMissingLabel: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + const seen = { posted: [] as string[], removed: [] as string[], checkRunCreated: false }; + stubTypeLabelFetch(212, seen); + + await processJob(env, { + type: "github-webhook", + deliveryId: "type-label-maintainer-author", + 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" } }, + pull_request: { number: 212, title: "fix: internal cleanup", state: "open", user: { login: "org-member" }, author_association: "MEMBER", head: { sha: "sha212" }, labels: [], body: "Internal." }, + }, + }); + + expect(seen.posted).toEqual(["gittensor:bug"]); + expect(seen.posted).not.toContain("gittensor"); + }); + + it("applies the type label to a bot-authored PR and keeps the three type labels mutually exclusive", async () => { + 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 upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "label_only", + autoLabelEnabled: true, + createMissingLabel: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + const seen = { posted: [] as string[], removed: [] as string[], checkRunCreated: false }; + stubTypeLabelFetch(213, seen); + + await processJob(env, { + type: "github-webhook", + deliveryId: "type-label-bot-author", + 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" } }, + pull_request: { number: 213, title: "feat: add retry backoff", state: "open", user: { login: "renovate[bot]", type: "Bot" }, head: { sha: "sha213" }, labels: [], body: "Automated." }, + }, + }); + + expect(seen.posted).toEqual(["gittensor:feature"]); + expect(seen.removed.sort()).toEqual(["gittensor:bug", "gittensor:priority"]); + }); + + it("applies the type label when publicSurface: comment_only makes the base context label structurally impossible", async () => { + 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 upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "comment_only", + autoLabelEnabled: true, + createMissingLabel: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + const seen = { posted: [] as string[], removed: [] as string[], checkRunCreated: false }; + stubTypeLabelFetch(214, seen); + + await processJob(env, { + type: "github-webhook", + deliveryId: "type-label-comment-only-surface", + 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" } }, + pull_request: { number: 214, title: "fix: comment-only regression", state: "open", user: { login: "contributor" }, author_association: "NONE", head: { sha: "sha214" }, labels: [], body: "Fixes #1" }, + }, + }); + + expect(seen.posted).toEqual(["gittensor:bug"]); + }); + + it("typeLabelsEnabled: false does not suppress the base context label for a confirmed contributor", async () => { + 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 upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "label_only", + autoLabelEnabled: true, + typeLabelsEnabled: false, + createMissingLabel: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "confirmed", snapshot: queueMinerSnapshot("contributor") }, 60_000); + const seen = { posted: [] as string[], removed: [] as string[], checkRunCreated: false }; + stubTypeLabelFetch(215, seen); + + await processJob(env, { + type: "github-webhook", + deliveryId: "type-label-disabled-confirmed-contributor", + 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" } }, + pull_request: { number: 215, title: "fix: confirmed contributor path", state: "open", user: { login: "contributor" }, author_association: "NONE", head: { sha: "sha215" }, labels: [], body: "Fixes #1" }, + }, + }); + + expect(seen.posted).toEqual(["gittensor"]); + }); + + it("posts the Gittensory Context check run independently of both label families being off, with zero label writes", async () => { + 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 upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + typeLabelsEnabled: false, + checkRunMode: "enabled", + gateCheckMode: "off", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "confirmed", snapshot: queueMinerSnapshot("contributor") }, 60_000); + const seen = { posted: [] as string[], removed: [] as string[], checkRunCreated: false }; + stubTypeLabelFetch(216, seen); + + await processJob(env, { + type: "github-webhook", + deliveryId: "type-label-checkrun-independent", + 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" } }, + pull_request: { number: 216, title: "fix: check-run independence", state: "open", user: { login: "contributor" }, author_association: "NONE", head: { sha: "sha216" }, labels: [], body: "Fixes #1" }, + }, + }); + + expect(seen.checkRunCreated).toBe(true); + expect(seen.posted).toEqual([]); + expect(seen.removed).toEqual([]); + }); + }); }); function completeSegment(repoFullName: string, segment: "labels" | "open_issues" | "open_pull_requests") {