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") {