From 54de4fcf7663ee5187a83700b40023fb3803af51 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:59:38 -0700 Subject: [PATCH] feat(settings): add the per-repo contributor blacklist config layer (#1425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anti-abuse foundation: a config-driven contributor blacklist so banned offenders (plagiarists / farmers) can be handled automatically by the converged engine. This PR is the data + resolution layer only — NO behavior change yet (the resolved list is unused, so the engine is byte-identical); the deterministic disposition lands in the follow-up. Config-as-code parity, layered like every other setting (.gittensory.yml > DB): - RepositorySettings.contributorBlacklist + the public-safe ContributorBlacklistEntry type (login + optional reason/evidence/addedAt; logins are public data). - repository_settings.contributor_blacklist_json column + migration 0072 (default []). - the settings resolver (DB read/serialize), the .gittensory.yml settings: overlay, OpenAPI, and the settings PUT route. - src/settings/contributor-blacklist.ts: pure normalize (login-pattern validation, public-safe metadata, de-dup, caps), findBlacklistEntry / isAuthorBlacklisted (case-insensitive), and mergeContributorBlacklists (the global-union primitive for the shared list, used at the point of use). Never hard-coded for any repo. Part of #1425 / #1409. --- apps/gittensory-ui/public/openapi.json | 26 ++++++ migrations/0072_contributor_blacklist.sql | 5 + src/api/routes.ts | 8 ++ src/db/repositories.ts | 10 ++ src/db/schema.ts | 2 + src/openapi/schemas.ts | 10 ++ src/settings/contributor-blacklist.ts | 90 ++++++++++++++++++ src/signals/focus-manifest.ts | 10 ++ src/types.ts | 18 ++++ test/unit/contributor-blacklist.test.ts | 106 ++++++++++++++++++++++ test/unit/focus-manifest.test.ts | 10 ++ 11 files changed, 295 insertions(+) create mode 100644 migrations/0072_contributor_blacklist.sql create mode 100644 src/settings/contributor-blacklist.ts create mode 100644 test/unit/contributor-blacklist.test.ts diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index b879fdc255..fc762976e3 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8152,6 +8152,32 @@ "advisory", "block" ] + }, + "contributorBlacklist": { + "type": "array", + "items": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + } + }, + "addedAt": { + "type": "string" + } + }, + "required": [ + "login" + ] + } } }, "required": [ diff --git a/migrations/0072_contributor_blacklist.sql b/migrations/0072_contributor_blacklist.sql new file mode 100644 index 0000000000..c436c4bfac --- /dev/null +++ b/migrations/0072_contributor_blacklist.sql @@ -0,0 +1,5 @@ +-- Per-repo contributor blacklist (#1425, anti-abuse): a JSON array of banned-login entries +-- ({ login, reason?, evidence?, addedAt? }) the converged engine deterministically closes a PR/issue against, +-- ahead of any merit/CI/AI analysis. Layered like other settings (.gittensory.yml > DB) and unioned with the +-- shared/global list at the point of use. Defaults to an empty list, so existing rows are byte-identical. +ALTER TABLE repository_settings ADD COLUMN contributor_blacklist_json TEXT NOT NULL DEFAULT '[]'; diff --git a/src/api/routes.ts b/src/api/routes.ts index 92c483f570..bf7a9c5bf1 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -26,6 +26,7 @@ import { } from "../auth/security"; import { normalizeGittBountySnapshot } from "../bounties/ingest"; import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization"; +import { normalizeContributorBlacklist } from "../settings/contributor-blacklist"; import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; import { countOpenIssues, @@ -630,6 +631,12 @@ const repositorySettingsSchema = z.object({ commands: z.record(z.string().trim().min(1).max(64), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4)).optional(), }) .default(DEFAULT_COMMAND_AUTHORIZATION_POLICY), + // Per-repo contributor blacklist (#1425). Loose by design — the DB layer normalizes/validates each entry + // (login pattern, public-safe metadata, de-dup, caps), so invalid entries are dropped on persist. + contributorBlacklist: z + .array(z.object({ login: z.string(), reason: z.string().optional(), evidence: z.array(z.string()).optional(), addedAt: z.string().optional() })) + .max(1000) + .default([]), }); // #130 maintainer self-serve settings editor. A PATCH-style subset: every field optional so the maintainer @@ -3362,6 +3369,7 @@ export function createApp() { privateTrustEnabled: parsed.data.privateTrustEnabled, badgeEnabled: parsed.data.badgeEnabled, commandAuthorization: normalizeCommandAuthorizationPolicy(parsed.data.commandAuthorization).policy, + contributorBlacklist: normalizeContributorBlacklist(parsed.data.contributorBlacklist).entries, }), ); }); diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 73921b5acc..1ab1210272 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -159,6 +159,7 @@ import type { import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } from "../gittensor/api"; import { classifyMcpClientVersion, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPORTED_MCP_VERSION } from "../services/mcp-compatibility"; import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization"; +import { normalizeContributorBlacklist } from "../settings/contributor-blacklist"; import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy, DEFAULT_AUTO_MAINTAIN_POLICY } from "../settings/autonomy"; import { decryptSecret, encryptSecret, sha256Hex } from "../utils/crypto"; import { jsonString, nowIso, parseJson, repoParts } from "../utils/json"; @@ -442,6 +443,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise agentPaused: false, agentDryRun: false, commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy, + contributorBlacklist: [], autonomy: {}, autoMaintain: { ...DEFAULT_AUTO_MAINTAIN_POLICY }, }; @@ -482,6 +484,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise agentPaused: row.agentPaused, agentDryRun: row.agentDryRun, commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson), + contributorBlacklist: parseContributorBlacklist(row.contributorBlacklistJson), autonomy: parseAutonomyPolicy(row.autonomyJson), autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson), createdAt: row.createdAt, @@ -526,6 +529,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial(value, null)).policy; } +function parseContributorBlacklist(value: string): RepositorySettings["contributorBlacklist"] { + return normalizeContributorBlacklist(parseJson(value, null)).entries; +} + function parseAutonomyPolicy(value: string): AutonomyPolicy { return normalizeAutonomyPolicy(parseJson(value, null)); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 4ed30b32aa..ac5546b523 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -76,6 +76,8 @@ export const repositorySettings = sqliteTable("repository_settings", { privateTrustEnabled: integer("private_trust_enabled", { mode: "boolean" }).notNull().default(true), badgeEnabled: integer("badge_enabled", { mode: "boolean" }).notNull().default(false), commandAuthorizationJson: text("command_authorization_json").notNull().default("{}"), + // Per-repo contributor blacklist (#1425): a JSON array of { login, reason?, evidence?, addedAt? } entries. + contributorBlacklistJson: text("contributor_blacklist_json").notNull().default("[]"), autonomyJson: text("autonomy_json").notNull().default("{}"), autoMaintainJson: text("auto_maintain_json").notNull().default("{}"), agentPaused: integer("agent_paused", { mode: "boolean" }).notNull().default(false), diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 5ac05c0465..ea4e56bfd0 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -613,6 +613,16 @@ export const RepositorySettingsSchema = z default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])), commands: z.record(z.string(), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"]))), }), + contributorBlacklist: z + .array( + z.object({ + login: z.string(), + reason: z.string().optional(), + evidence: z.array(z.string()).optional(), + addedAt: z.string().optional(), + }), + ) + .optional(), autonomy: z .record(z.enum(["review", "request_changes", "approve", "merge", "close", "label"]), z.enum(["observe", "suggest", "propose", "auto_with_approval", "auto"])) .optional(), diff --git a/src/settings/contributor-blacklist.ts b/src/settings/contributor-blacklist.ts new file mode 100644 index 0000000000..269ecf19b5 --- /dev/null +++ b/src/settings/contributor-blacklist.ts @@ -0,0 +1,90 @@ +// Contributor blacklist (#1425, anti-abuse). Pure resolution + matching for the banned-login list the converged +// engine acts on. Config-driven and layered the same as other settings (`.gittensory.yml` > DB) and unioned with +// the shared/global list at the point of use — NEVER hard-coded for any repo. Logins are public data; entries +// carry only public-safe metadata (no wallets/hotkeys/trust-scores/private values). Mirrors the shape of +// command-authorization.ts (normalize → typed policy + warnings). +import type { ContributorBlacklistEntry } from "../types"; + +// GitHub logins: 1–39 chars, alphanumeric or single hyphens (not leading/trailing). Anything else is dropped so a +// malformed entry can never widen the match or break the close path. +const GITHUB_LOGIN = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/; +const MAX_ENTRIES = 1000; +const MAX_REASON_CHARS = 200; +const MAX_EVIDENCE = 10; +const MAX_EVIDENCE_CHARS = 500; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Normalize a raw blacklist value (DB JSON or `.gittensory.yml`) into validated, de-duplicated entries. Never + * throws: malformed entries are dropped with a warning. De-dup is by case-insensitive login (the FIRST wins, so + * its richer metadata is kept). */ +export function normalizeContributorBlacklist(input: unknown): { entries: ContributorBlacklistEntry[]; warnings: string[] } { + const warnings: string[] = []; + if (input === undefined || input === null) return { entries: [], warnings }; + if (!Array.isArray(input)) { + warnings.push("contributorBlacklist must be a list of entries; ignoring it."); + return { entries: [], warnings }; + } + const entries: ContributorBlacklistEntry[] = []; + const seen = new Set(); + for (const [index, raw] of input.entries()) { + if (entries.length >= MAX_ENTRIES) { + warnings.push(`contributorBlacklist is capped at ${MAX_ENTRIES} entries; dropping the rest.`); + break; + } + // Accept either a bare login string or a `{ login, ... }` object. + const record = typeof raw === "string" ? { login: raw } : raw; + if (!isRecord(record) || typeof record.login !== "string") { + warnings.push(`contributorBlacklist[${index}] needs a string login; ignoring it.`); + continue; + } + const login = record.login.trim(); + if (!GITHUB_LOGIN.test(login)) { + warnings.push(`contributorBlacklist[${index}].login is not a valid GitHub login; ignoring it.`); + continue; + } + const key = login.toLowerCase(); + if (seen.has(key)) continue; // first occurrence wins + seen.add(key); + const entry: ContributorBlacklistEntry = { login }; + if (typeof record.reason === "string" && record.reason.trim().length > 0) entry.reason = record.reason.trim().slice(0, MAX_REASON_CHARS); + if (Array.isArray(record.evidence)) { + const evidence = record.evidence.filter((ref): ref is string => typeof ref === "string" && ref.trim().length > 0).map((ref) => ref.trim().slice(0, MAX_EVIDENCE_CHARS)).slice(0, MAX_EVIDENCE); + if (evidence.length > 0) entry.evidence = evidence; + } + if (typeof record.addedAt === "string" && record.addedAt.trim().length > 0) entry.addedAt = record.addedAt.trim(); + entries.push(entry); + } + return { entries, warnings }; +} + +/** The blacklist entry matching `login` (case-insensitive), or null. */ +export function findBlacklistEntry(login: string | null | undefined, entries: ContributorBlacklistEntry[]): ContributorBlacklistEntry | null { + if (!login) return null; + const key = login.toLowerCase(); + return entries.find((entry) => entry.login.toLowerCase() === key) ?? null; +} + +/** True iff `login` is on the resolved blacklist. */ +export function isAuthorBlacklisted(login: string | null | undefined, entries: ContributorBlacklistEntry[]): boolean { + return findBlacklistEntry(login, entries) !== null; +} + +/** Union multiple blacklist sources (e.g. the shared/global list + the per-repo list) by case-insensitive login. + * A login on ANY source is blocked; the FIRST source's entry wins on a duplicate so earlier (more authoritative) + * metadata is preserved. Already-normalized inputs in, de-duplicated entries out. */ +export function mergeContributorBlacklists(...lists: ContributorBlacklistEntry[][]): ContributorBlacklistEntry[] { + const merged: ContributorBlacklistEntry[] = []; + const seen = new Set(); + for (const list of lists) { + for (const entry of list) { + const key = entry.login.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + merged.push(entry); + } + } + return merged; +} diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 46912a37e9..58f5d2bbcc 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -1,6 +1,7 @@ import { parse as parseYaml } from "yaml"; import type { GatePolicyPack, GateRuleMode, JsonValue, RepositorySettings } from "../types"; import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy } from "../settings/autonomy"; +import { normalizeContributorBlacklist } from "../settings/contributor-blacklist"; export type FocusManifestSource = "repo_file" | "api_record" | "none"; export type FocusManifestLinkedIssuePolicy = "required" | "preferred" | "optional"; @@ -73,6 +74,7 @@ export type FocusManifestSettings = Partial< | "autoMaintain" | "agentPaused" | "agentDryRun" + | "contributorBlacklist" > >; @@ -493,6 +495,14 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) if (typeof r.autoMaintain === "object" && r.autoMaintain !== null && !Array.isArray(r.autoMaintain)) { out.autoMaintain = normalizeAutoMaintainPolicy(r.autoMaintain); } + // Contributor blacklist (#1425): `settings.contributorBlacklist` is a list of banned-login entries. Only set it + // when at least one VALID entry survives normalization, so a malformed block never blanks the DB-configured + // list via the resolver's `{...dbSettings, ...manifest.settings}` overlay. Normalization warnings are folded in. + if (r.contributorBlacklist !== undefined) { + const { entries, warnings: blacklistWarnings } = normalizeContributorBlacklist(r.contributorBlacklist); + warnings.push(...blacklistWarnings); + if (entries.length > 0) out.contributorBlacklist = entries; + } return out; } diff --git a/src/types.ts b/src/types.ts index 039f9e11ec..6ed898293b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -554,6 +554,11 @@ export type RepositorySettings = { * (default false); optional so existing settings fixtures/callers need not be touched. */ badgeEnabled?: boolean | undefined; commandAuthorization?: RepositoryCommandAuthorizationPolicy | undefined; + /** Per-repo contributor blacklist (#1425, anti-abuse): banned GitHub logins whose PRs/issues the engine + * deterministically closes ahead of merit review. Layered the same as other settings (`.gittensory.yml` > + * DB) and unioned with the shared/global list at the point of use. Always populated by the DB layer + * (default `[]`); optional so existing settings fixtures/callers need not be touched. */ + contributorBlacklist?: ContributorBlacklistEntry[] | undefined; /** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default * `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers * need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */ @@ -578,6 +583,19 @@ export type RepositoryCommandAuthorizationPolicy = { commands: Record; }; +/** A blocked contributor (#1425, anti-abuse): a GitHub `login` plus optional PUBLIC metadata. The converged + * engine short-circuits a blacklisted author's PR/issue to a deterministic close ahead of any merit/CI/AI + * analysis. `login` is public data — entries NEVER carry wallets/hotkeys/trust-scores/private values. */ +export type ContributorBlacklistEntry = { + login: string; + /** Why the account is blocked, e.g. `plagiarism` / `farming`. Free-text, public-safe. */ + reason?: string | undefined; + /** Public PR/issue URLs (or other public refs) evidencing the block. */ + evidence?: string[] | undefined; + /** ISO-8601 date the entry was added. */ + addedAt?: string | undefined; +}; + /** Agent-layer graduated autonomy (#773), least → most autonomous. `observe` is the deny-by-default floor: * gittensory watches but never acts. `suggest`/`propose` surface guidance/concrete proposals without * executing; `auto_with_approval` executes behind a human approval gate (#779); `auto` executes directly. */ diff --git a/test/unit/contributor-blacklist.test.ts b/test/unit/contributor-blacklist.test.ts new file mode 100644 index 0000000000..8618f2ec91 --- /dev/null +++ b/test/unit/contributor-blacklist.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { findBlacklistEntry, isAuthorBlacklisted, mergeContributorBlacklists, normalizeContributorBlacklist } from "../../src/settings/contributor-blacklist"; +import { getRepositorySettings, upsertRepositorySettings } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; +import type { ContributorBlacklistEntry } from "../../src/types"; + +describe("contributor blacklist DB round-trip (#1425)", () => { + it("persists + resolves the per-repo blacklist through the DB, dropping invalid entries", async () => { + const env = createTestEnv(); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", contributorBlacklist: [{ login: "plagiarist", reason: "plagiarism" }, { login: "-invalid" }, { login: "farmer" }] }); + const settings = await getRepositorySettings(env, "owner/repo"); + expect(settings.contributorBlacklist?.map((e) => e.login)).toEqual(["plagiarist", "farmer"]); + expect(settings.contributorBlacklist?.[0]).toEqual({ login: "plagiarist", reason: "plagiarism" }); + }); + + it("defaults to an empty list for an unconfigured repo", async () => { + const settings = await getRepositorySettings(createTestEnv(), "owner/none"); + expect(settings.contributorBlacklist).toEqual([]); + }); +}); + +describe("normalizeContributorBlacklist (#1425)", () => { + it("returns [] for null/undefined and a non-array (with a warning)", () => { + expect(normalizeContributorBlacklist(undefined).entries).toEqual([]); + expect(normalizeContributorBlacklist(null).entries).toEqual([]); + const notArray = normalizeContributorBlacklist({ login: "x" }); + expect(notArray.entries).toEqual([]); + expect(notArray.warnings[0]).toMatch(/must be a list/); + }); + + it("accepts a bare login string and a full entry object", () => { + const { entries } = normalizeContributorBlacklist(["octocat", { login: "mona", reason: "farming", evidence: ["https://github.com/o/r/pull/1"], addedAt: "2026-06-26T00:00:00Z" }]); + expect(entries).toEqual([ + { login: "octocat" }, + { login: "mona", reason: "farming", evidence: ["https://github.com/o/r/pull/1"], addedAt: "2026-06-26T00:00:00Z" }, + ]); + }); + + it("drops entries with no/invalid login", () => { + const { entries, warnings } = normalizeContributorBlacklist([{ reason: "no login" }, 42, { login: "-bad" }, { login: "bad-" }, { login: "a--b" }, { login: "has space" }, { login: "a".repeat(40) }]); + expect(entries).toEqual([]); + expect(warnings.length).toBeGreaterThanOrEqual(5); + }); + + it("accepts valid GitHub logins (alnum, single internal hyphen, ≤39 chars)", () => { + const { entries } = normalizeContributorBlacklist(["a-b", "user123", "a".repeat(39)]); + expect(entries.map((e) => e.login)).toEqual(["a-b", "user123", "a".repeat(39)]); + }); + + it("de-duplicates by case-insensitive login, keeping the FIRST (richer) occurrence", () => { + const { entries } = normalizeContributorBlacklist([{ login: "Mona", reason: "first" }, { login: "mona", reason: "second" }]); + expect(entries).toEqual([{ login: "Mona", reason: "first" }]); + }); + + it("caps the list and warns when over the limit", () => { + const many = Array.from({ length: 1005 }, (_, i) => `user${i}`); + const { entries, warnings } = normalizeContributorBlacklist(many); + expect(entries).toHaveLength(1000); + expect(warnings.some((w) => w.includes("capped"))).toBe(true); + }); + + it("normalizes metadata: trims + caps reason, filters/caps evidence, omits empties", () => { + const { entries } = normalizeContributorBlacklist([ + { login: "a", reason: " spaced ", evidence: [" url ", "", 5, "u2"], addedAt: " 2026-01-01 " }, + { login: "b", reason: " ", evidence: [""] }, // reason all-whitespace + evidence all-empty → both omitted + { login: "c", reason: "x".repeat(300), evidence: Array.from({ length: 20 }, (_, i) => `e${i}`) }, + ]); + expect(entries[0]).toEqual({ login: "a", reason: "spaced", evidence: ["url", "u2"], addedAt: "2026-01-01" }); + expect(entries[1]).toEqual({ login: "b" }); // empty reason/evidence omitted + expect(entries[2]?.reason?.length).toBe(200); // reason capped + expect(entries[2]?.evidence).toHaveLength(10); // evidence capped + }); +}); + +describe("findBlacklistEntry / isAuthorBlacklisted", () => { + const list: ContributorBlacklistEntry[] = [{ login: "Mona", reason: "farming" }, { login: "octocat" }]; + + it("matches case-insensitively and returns the entry", () => { + expect(findBlacklistEntry("mona", list)?.reason).toBe("farming"); + expect(findBlacklistEntry("OCTOCAT", list)?.login).toBe("octocat"); + expect(isAuthorBlacklisted("Mona", list)).toBe(true); + }); + + it("returns null/false for a non-match or a missing login", () => { + expect(findBlacklistEntry("stranger", list)).toBeNull(); + expect(findBlacklistEntry(null, list)).toBeNull(); + expect(findBlacklistEntry(undefined, list)).toBeNull(); + expect(isAuthorBlacklisted("stranger", list)).toBe(false); + expect(isAuthorBlacklisted(null, list)).toBe(false); + }); +}); + +describe("mergeContributorBlacklists (global ∪ per-repo)", () => { + it("unions by case-insensitive login, first source's entry wins on a dup", () => { + const global: ContributorBlacklistEntry[] = [{ login: "Mona", reason: "global" }, { login: "abuser" }]; + const perRepo: ContributorBlacklistEntry[] = [{ login: "mona", reason: "repo" }, { login: "repo-only" }]; + const merged = mergeContributorBlacklists(global, perRepo); + expect(merged.map((e) => e.login.toLowerCase())).toEqual(["mona", "abuser", "repo-only"]); + expect(findBlacklistEntry("mona", merged)?.reason).toBe("global"); // first source wins + }); + + it("returns [] for no sources / all-empty sources", () => { + expect(mergeContributorBlacklists()).toEqual([]); + expect(mergeContributorBlacklists([], [])).toEqual([]); + }); +}); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 6cd102ab9e..4de1c8a607 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -986,6 +986,16 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(ignored.autoMaintain).toEqual({ requireApprovals: 2, mergeMethod: "merge" }); }); + it("parses + resolves contributorBlacklist from the settings: block, overlaying the DB list (#1425)", () => { + const manifest = parseFocusManifest({ settings: { contributorBlacklist: ["plagiarist1", { login: "farmer2", reason: "farming" }, { login: "-bad" }] } }); + expect(manifest.settings.contributorBlacklist).toEqual([{ login: "plagiarist1" }, { login: "farmer2", reason: "farming" }]); // invalid login dropped + const eff = resolveEffectiveSettings({ contributorBlacklist: [{ login: "db-only" }] } as unknown as RepositorySettings, manifest); + expect(eff.contributorBlacklist?.map((e) => e.login)).toEqual(["plagiarist1", "farmer2"]); // yml overlays DB + // An empty/all-invalid block never blanks the DB-configured list (only set when a valid entry survives). + const noOverride = resolveEffectiveSettings({ contributorBlacklist: [{ login: "keep-me" }] } as unknown as RepositorySettings, parseFocusManifest({ settings: { contributorBlacklist: [{ login: "" }] } })); + expect(noOverride.contributorBlacklist?.map((e) => e.login)).toEqual(["keep-me"]); + }); + it("resolveEffectiveSettings overlays settings: over DB and lets gate: win for gate fields", () => { const db = { commentMode: "off", gateCheckMode: "off", linkedIssueGateMode: "off", duplicatePrGateMode: "off", autoLabelEnabled: true } as unknown as RepositorySettings; const eff = resolveEffectiveSettings(