diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index fc762976e3..9b5ab8fee9 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8178,6 +8178,9 @@ "login" ] } + }, + "blacklistLabel": { + "type": "string" } }, "required": [ @@ -8200,6 +8203,7 @@ "slopAiAdvisory", "autoLabelEnabled", "gittensorLabel", + "blacklistLabel", "createMissingLabel", "publicSurface", "includeMaintainerAuthors", @@ -8803,6 +8807,9 @@ "advisory", "block" ] + }, + "blacklistLabel": { + "type": "string" } }, "required": [ @@ -8824,6 +8831,7 @@ "firstTimeContributorGrace", "autoLabelEnabled", "gittensorLabel", + "blacklistLabel", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", diff --git a/docs/review-configuration.md b/docs/review-configuration.md index e08329b12c..3188f2ed78 100644 --- a/docs/review-configuration.md +++ b/docs/review-configuration.md @@ -154,6 +154,14 @@ Everything a maintainer can toggle in the dashboard can be set as code under `se | Autonomy dial | `autonomy` | per-action-class level (`observe`…`auto`) | `{}` (= `observe`, deny-by-default) | | Auto-maintain policy | `autoMaintain` | `{ mergeMethod, requireApprovals }` | `squash` / `1` | | Command authorization | `commandAuthorization` | role policy | built-in default policy | +| Contributor blacklist | `contributorBlacklist` | list of `{ login, reason?, evidence?, addedAt? }` (login required) | `[]` | +| Blacklist label | `blacklistLabel` | string | `slop` | + +The **contributor blacklist** is layered like every other setting (`.gittensory.yml` +`settings.contributorBlacklist` > database) and is unioned with the shared/global list. Logins are +public data, so entries carry only public-safe metadata (a `reason`, `evidence` URLs, an `addedAt` +date) — never wallets, hotkeys, trust scores, or private values. `blacklistLabel` (default `slop`) is +the label the engine applies to a blacklisted author's PR. ### Example `.gittensory.yml` @@ -196,6 +204,14 @@ settings: checkRunMode: enabled checkRunDetailLevel: standard badgeEnabled: true + blacklistLabel: slop + contributorBlacklist: + - login: known-plagiarist + reason: plagiarism + evidence: + - https://github.com/owner/repo/pull/1 + addedAt: "2026-06-26" + - bad-farmer # bare login shorthand is also accepted ``` --- diff --git a/migrations/0073_blacklist_label.sql b/migrations/0073_blacklist_label.sql new file mode 100644 index 0000000000..e89bda3d45 --- /dev/null +++ b/migrations/0073_blacklist_label.sql @@ -0,0 +1,3 @@ +-- #1425: per-repo configurable label for a blacklisted contributor's PR/issue. Default "slop" so the +-- deterministic blacklist disposition works regardless of the label a repo uses. +ALTER TABLE repository_settings ADD COLUMN blacklist_label TEXT NOT NULL DEFAULT 'slop'; diff --git a/src/api/routes.ts b/src/api/routes.ts index bf7a9c5bf1..cb20ef4a76 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -618,6 +618,7 @@ const repositorySettingsSchema = z.object({ aiReviewModel: z.string().trim().min(1).max(120).nullable().optional(), autoLabelEnabled: z.boolean().default(true), gittensorLabel: z.string().trim().min(1).max(50).default("gittensor"), + blacklistLabel: z.string().trim().min(1).max(50).default("slop"), createMissingLabel: z.boolean().default(true), publicSurface: z.enum(["off", "comment_and_label", "comment_only", "label_only"]).default("comment_and_label"), includeMaintainerAuthors: z.boolean().default(false), @@ -666,6 +667,7 @@ const maintainerSettingsSchema = z slopAiAdvisory: z.boolean(), autoLabelEnabled: z.boolean(), gittensorLabel: z.string().trim().min(1).max(50), + blacklistLabel: z.string().trim().min(1).max(50), createMissingLabel: z.boolean(), includeMaintainerAuthors: z.boolean(), requireLinkedIssue: z.boolean(), @@ -3361,6 +3363,7 @@ export function createApp() { aiReviewModel: parsed.data.aiReviewModel, autoLabelEnabled: parsed.data.autoLabelEnabled, gittensorLabel: parsed.data.gittensorLabel, + blacklistLabel: parsed.data.blacklistLabel, createMissingLabel: parsed.data.createMissingLabel, publicSurface: parsed.data.publicSurface, includeMaintainerAuthors: parsed.data.includeMaintainerAuthors, diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 1ab1210272..1d8e7a55d4 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -433,6 +433,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise aiReviewModel: null, autoLabelEnabled: true, gittensorLabel: "gittensor", + blacklistLabel: "slop", createMissingLabel: true, publicSurface: "comment_and_label", includeMaintainerAuthors: false, @@ -474,6 +475,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise aiReviewModel: row.aiReviewModel ?? null, autoLabelEnabled: row.autoLabelEnabled, gittensorLabel: row.gittensorLabel, + blacklistLabel: row.blacklistLabel, createMissingLabel: row.createMissingLabel, publicSurface: parsePublicSurface(row.publicSurface), includeMaintainerAuthors: row.includeMaintainerAuthors, @@ -519,6 +521,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial >; @@ -477,6 +478,8 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) if (aiReviewModel !== null) out.aiReviewModel = aiReviewModel; const gittensorLabel = normalizeOptionalString(r.gittensorLabel, "settings.gittensorLabel", warnings); if (gittensorLabel !== null) out.gittensorLabel = gittensorLabel; + const blacklistLabel = normalizeOptionalString(r.blacklistLabel, "settings.blacklistLabel", warnings); + if (blacklistLabel !== null) out.blacklistLabel = blacklistLabel; 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", "autoLabelEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { diff --git a/src/types.ts b/src/types.ts index 6ed898293b..7c1588a8b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -559,6 +559,11 @@ export type RepositorySettings = { * 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; + /** The label applied to a blacklisted contributor's PR (#1425). Configurable per-repo (dashboard/DB + + * `.gittensory.yml` `settings.blacklistLabel`); defaults to `"slop"` so the disposition works regardless of + * the label a repo sets. Always populated by the DB layer (default `"slop"`); optional so existing settings + * fixtures/callers need not be touched (mirrors the sibling `contributorBlacklist`). */ + blacklistLabel?: string | 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`. */ diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 4de1c8a607..316e7682a5 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -986,11 +986,13 @@ 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" }] } }); + it("parses + resolves contributorBlacklist + blacklistLabel from the settings: block, overlaying the DB (#1425)", () => { + const manifest = parseFocusManifest({ settings: { contributorBlacklist: ["plagiarist1", { login: "farmer2", reason: "farming" }, { login: "-bad" }], blacklistLabel: "abuse" } }); expect(manifest.settings.contributorBlacklist).toEqual([{ login: "plagiarist1" }, { login: "farmer2", reason: "farming" }]); // invalid login dropped + expect(manifest.settings.blacklistLabel).toBe("abuse"); 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 + expect(eff.blacklistLabel).toBe("abuse"); // configurable label, not hardcoded // 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"]); diff --git a/test/unit/routes-ai-byok.test.ts b/test/unit/routes-ai-byok.test.ts index 8eea1219ae..80149883fb 100644 --- a/test/unit/routes-ai-byok.test.ts +++ b/test/unit/routes-ai-byok.test.ts @@ -32,7 +32,7 @@ describe("maintainer AI-review config route", () => { it("sets mode/byok/provider/model and preserves unrelated settings", async () => { const app = createApp(); const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); - await upsertRepositorySettings(env, { repoFullName: REPO, gateCheckMode: "enabled", gittensorLabel: "custom-label" }); + await upsertRepositorySettings(env, { repoFullName: REPO, gateCheckMode: "enabled", gittensorLabel: "custom-label", blacklistLabel: "abuse" }); const res = await app.request( `/v1/repos/${REPO}/ai-review`, { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "block", byok: true, provider: "anthropic", model: "claude-3-5-sonnet-latest" }) }, @@ -44,6 +44,7 @@ describe("maintainer AI-review config route", () => { expect(settings.aiReviewMode).toBe("block"); expect(settings.gateCheckMode).toBe("enabled"); // preserved expect(settings.gittensorLabel).toBe("custom-label"); // preserved + expect(settings.blacklistLabel).toBe("abuse"); // #1425 round-trips through the DB }); it("accepts a config without provider/model (stored as null)", async () => {