diff --git a/src/settings/autonomy.ts b/src/settings/autonomy.ts index af1e91adf1..030df6d75d 100644 --- a/src/settings/autonomy.ts +++ b/src/settings/autonomy.ts @@ -1,93 +1,5 @@ -import type { AgentActionClass, AutoMaintainPolicy, AutoMergeMethod, AutonomyLevel, AutonomyPolicy } from "../types"; - -// The graduated autonomy dial (#773), ordered least → most autonomous. Every later agent-layer phase reads -// this BEFORE acting. `observe` is the deny-by-default floor — loopover watches but never takes an action. -// (#4620: `suggest`/`propose` removed -- both were 100% behaviorally identical to `observe`, see -// AutonomyLevel's own doc comment.) -export const AUTONOMY_LEVELS = ["observe", "auto_with_approval", "auto"] as const; - -// The write-action classes the maintainer auto-maintain layer (#778) can take on a PR. `review_state_label` -// (#label-scoping) is a separate class from `label`: it gates the planner's own disposition-communication -// labels (ready-to-merge / changes-requested / manual-review / migration-collision / pending-closure / -// new-account), independent of the anti-abuse enforcement labels (blacklist/contributor-cap/review-nag), which -// ride on `close` instead -- see agent-actions.ts. `assign` (#3182) is its own independent class, same shape: -// best-effort assignment of the PR's opening contributor, unrelated to merge/close/approve. -export const AGENT_ACTION_CLASSES = ["review", "request_changes", "approve", "merge", "close", "label", "review_state_label", "update_branch", "assign"] as const; - -// Deny-by-default: any action class with no explicit, valid level resolves to this. -export const DEFAULT_AUTONOMY_LEVEL: AutonomyLevel = "observe"; - -const AUTONOMY_LEVEL_SET = new Set(AUTONOMY_LEVELS); - -/** - * Resolve the configured autonomy level for one action class on a repo. THE single gate the action layer - * (#778) consults before any write action. Deny-by-default: an unset (or malformed) action class is - * `observe` — loopover observes but never acts. Pure. - */ -export function resolveAutonomy(autonomy: AutonomyPolicy | null | undefined, actionClass: AgentActionClass): AutonomyLevel { - return autonomy?.[actionClass] ?? DEFAULT_AUTONOMY_LEVEL; -} - -/** True when the level permits the agent to actually execute the action (directly or behind an approval). */ -export function isActingAutonomyLevel(level: AutonomyLevel): boolean { - return level === "auto" || level === "auto_with_approval"; -} - -/** - * True when a repo has opted into the agent layer at all — i.e. at least one action class has an acting - * autonomy level. The deny-by-default floor (every class `observe`) is NOT configured. The scheduled - * re-gate sweep (#777) uses this to skip repos that never asked the agent to act. Pure. - */ -export function isAgentConfigured(autonomy: AutonomyPolicy | null | undefined): boolean { - return AGENT_ACTION_CLASSES.some((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); -} - -/** True when the action must pass a human approval gate (#779) before it executes. */ -export function autonomyRequiresApproval(level: AutonomyLevel): boolean { - return level === "auto_with_approval"; -} - -/** - * Parse/validate an arbitrary value into an AutonomyPolicy: keep only known action classes mapped to known - * levels, drop everything else. Deny-by-default by omission. Used for the DB row, the API body, and the - * `.loopover.yml` settings block. Pure. - */ -export function normalizeAutonomyPolicy(input: unknown): AutonomyPolicy { - if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; - const record = input as Record; - const policy: AutonomyPolicy = {}; - for (const actionClass of AGENT_ACTION_CLASSES) { - const value = record[actionClass]; - if (typeof value === "string" && AUTONOMY_LEVEL_SET.has(value)) { - policy[actionClass] = value as AutonomyLevel; - } - } - return policy; -} - -// Auto-maintain policy (#774): how an action behaves once its autonomy level permits acting. -export const AUTO_MERGE_METHODS = ["merge", "squash", "rebase"] as const; -const AUTO_MERGE_METHOD_SET = new Set(AUTO_MERGE_METHODS); - -// Conservative defaults: squash (the tidiest history) + a single human approval before any auto-merge. -export const DEFAULT_AUTO_MAINTAIN_POLICY: AutoMaintainPolicy = { requireApprovals: 1, mergeMethod: "squash" }; - -// Approvals are clamped to a sane band so a malformed config can't disable the gate (negative) or stall it. -const MAX_REQUIRE_APPROVALS = 10; - -/** - * Parse/validate an arbitrary value into an AutoMaintainPolicy, filling the conservative defaults for any - * missing/invalid field. `requireApprovals` is clamped to [0, 10]. Pure. - */ -export function normalizeAutoMaintainPolicy(input: unknown): AutoMaintainPolicy { - if (typeof input !== "object" || input === null || Array.isArray(input)) return { ...DEFAULT_AUTO_MAINTAIN_POLICY }; - const record = input as Record; - const rawApprovals = record.requireApprovals; - const requireApprovals = - typeof rawApprovals === "number" && Number.isFinite(rawApprovals) - ? Math.min(MAX_REQUIRE_APPROVALS, Math.max(0, Math.trunc(rawApprovals))) - : DEFAULT_AUTO_MAINTAIN_POLICY.requireApprovals; - const rawMethod = record.mergeMethod; - const mergeMethod = typeof rawMethod === "string" && AUTO_MERGE_METHOD_SET.has(rawMethod) ? (rawMethod as AutoMergeMethod) : DEFAULT_AUTO_MAINTAIN_POLICY.mergeMethod; - return { requireApprovals, mergeMethod }; -} +// autonomy, converged onto @loopover/engine (#4879, extended by #6194). This src/ file was a hand-maintained twin of +// the engine copy; it is now a thin re-export shim so the single implementation lives at +// packages/loopover-engine/src/settings/autonomy.ts (imported via relative source path, not the published package, to +// match this repo's existing engine-consumption convention — see src/signals/check-summary.ts). +export * from "../../packages/loopover-engine/src/settings/autonomy"; diff --git a/src/settings/command-authorization.ts b/src/settings/command-authorization.ts index f4039ee945..d62c2f7e94 100644 --- a/src/settings/command-authorization.ts +++ b/src/settings/command-authorization.ts @@ -1,270 +1,5 @@ -import type { CommandAuthorizationRole, RepositoryCommandAuthorizationPolicy } from "../types"; - -export const DEFAULT_COMMAND_AUTHORIZATION_POLICY: RepositoryCommandAuthorizationPolicy = { - default: ["maintainer", "collaborator", "confirmed_miner"], - commands: { - "queue-summary": ["maintainer", "collaborator"], - "confirmed-miners": ["maintainer", "collaborator"], - "review-now": ["maintainer", "collaborator"], - "needs-author": ["maintainer", "collaborator"], - "duplicate-clusters": ["maintainer", "collaborator"], - "burden-forecast": ["maintainer", "collaborator"], - "intake-health": ["maintainer", "collaborator"], - "outcome-patterns": ["maintainer", "collaborator"], - "noise-report": ["maintainer", "collaborator"], - "gate-override": ["maintainer", "collaborator"], - plan: ["maintainer", "collaborator"], - // #4595/#5084: chat is Ollama-only grounded LLM generation, a materially larger surface than ask's - // deterministic-only answer, so v1 started maintainer/collaborator-only. #5084 widens this to the PR's - // OWN author (never an arbitrary commenter on someone else's PR) -- but ONLY when commandRateLimitPolicy - // is "hold" for the repo, enforced in evaluateCommandAuthorization below, not just by operator convention. - // Explicit registration here (rather than falling through to `default`) also activates the - // MAINTAINER_ONLY_DEFAULT_COMMANDS clamp in normalizeCommandRoleList, so a self-hoster can't yml - // themselves into "any confirmed_miner" or similar widening beyond what's shipped here. - chat: ["maintainer", "collaborator", "pr_author"], - // #1960 PR control-surface verbs. "review" is deliberately widenable to confirmed_miner (same self-rerun - // precedent already applied to review-now, #824) — a confirmed miner may re-trigger review on their own PR. - // The rest (pause/resume/resolve/configuration/explain) are conservative maintainer/collaborator-only - // defaults out of the box; a maintainer who wants to widen them can do so via commandAuthorization overrides. - review: ["maintainer", "collaborator", "confirmed_miner"], - pause: ["maintainer", "collaborator"], - resume: ["maintainer", "collaborator"], - resolve: ["maintainer", "collaborator"], - configuration: ["maintainer", "collaborator"], - explain: ["maintainer", "collaborator"], - // #4195 (part of the #4189 E2E-test-generation epic): deliberately NARROWER than every command above -- - // "maintainer" ONLY, excluding "collaborator" and "confirmed_miner". This command can write real content - // (a generated test) attributed to the PR; a repo could grant a contributor/miner collaborator-level - // push access, and that tier must not be able to invoke test generation for their own scored PR (the - // exact loophole a click-to-generate button would otherwise open). The existing - // `maintainer_command_requires_maintainer` guard below already denies the PR's own author when they - // don't independently hold the `maintainer` role, so no bespoke pr_author check is needed here. - "generate-tests": ["maintainer"], - }, -}; - -const COMMAND_AUTHORIZATION_ROLES = new Set(["maintainer", "collaborator", "pr_author", "confirmed_miner"]); -// Roles that may remain configured on a maintainer-only command. The clamp drops only the spoofable -// plain `pr_author` role; `confirmed_miner` survives so a detected miner can self-trigger reruns (#824). -const MAINTAINER_COMMAND_AUTHORIZATION_ROLES = new Set(["maintainer", "collaborator", "confirmed_miner"]); -const MAINTAINER_ONLY_DEFAULT_COMMANDS = new Set(Object.keys(DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands)); -// #5084: commands where a `pr_author` match is only actually granted when commandRateLimitPolicy is "hold" for -// the repo -- checked in evaluateCommandAuthorization. Currently just `chat` (Ollama-only LLM generation); -// deliberately a narrow, explicit allowlist rather than inferring this from isAiCostBearingCommand, so widening -// it to another command later is a deliberate one-line addition, not an implicit side effect of an unrelated set. -const PR_AUTHOR_RATE_LIMITED_COMMANDS = new Set(["chat"]); - -export type CommandAuthorizationDecision = { - authorized: boolean; - reason: string; - actorKind: "maintainer" | "author" | "none"; - matchedRole: CommandAuthorizationRole | null; - allowedRoles: CommandAuthorizationRole[]; -}; - -export function normalizeCommandAuthorizationPolicy(input: unknown): { policy: RepositoryCommandAuthorizationPolicy; warnings: string[] } { - const warnings: string[] = []; - if (!isRecord(input)) { - if (input !== null && input !== undefined) warnings.push("commandAuthorization must be an object; using secure defaults."); - return { policy: clonePolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY), warnings }; - } - - const defaultRoles = normalizeRoleList(input.default, DEFAULT_COMMAND_AUTHORIZATION_POLICY.default, "default", warnings); - const commands: Record = { ...DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands }; - if (input.commands !== undefined) { - if (isRecord(input.commands)) { - for (const [command, roles] of Object.entries(input.commands)) { - const commandName = command.trim().toLowerCase(); - if (!/^[a-z][a-z-]{0,63}$/.test(commandName)) { - warnings.push(`Ignored malformed command authorization key: ${command.slice(0, 64)}`); - continue; - } - commands[commandName] = normalizeCommandRoleList(commandName, normalizeRoleList(roles, defaultRoles, commandName, warnings), warnings); - } - } else { - warnings.push("commandAuthorization.commands must be an object; using command defaults."); - } - } - - return { policy: { default: defaultRoles, commands }, warnings }; -} - -export function commandAuthorizationAllowedRoles(policy: RepositoryCommandAuthorizationPolicy | null | undefined, commandName: string): CommandAuthorizationRole[] { - const normalized = normalizeCommandAuthorizationPolicy(policy).policy; - // Policy command keys are stored normalized (trimmed + lowercased) by normalizeCommandAuthorizationPolicy, - // so the lookup MUST normalize the probe too. A raw mixed-case name (e.g. "Gate-Override") otherwise misses - // its restrictive override and silently falls back to the permissive default — under-stating the restriction. - const key = normalizeCommandName(commandName); - const commandRoles = Object.hasOwn(normalized.commands, key) ? normalized.commands[key] : undefined; - return dedupeRoles(commandRoles ?? normalized.default); -} - -function normalizeCommandName(commandName: string): string { - return commandName.trim().toLowerCase(); -} - -export function commandAuthorizationNeedsMinerDetection(args: { - policy?: RepositoryCommandAuthorizationPolicy | null | undefined; - commandName: string; - commenterLogin?: string | null | undefined; - commenterAssociation?: string | null | undefined; - pullRequestAuthorLogin?: string | null | undefined; -}): boolean { - const allowedRoles = commandAuthorizationAllowedRoles(args.policy, args.commandName); - if (!allowedRoles.includes("confirmed_miner")) return false; - if (!isSameLogin(args.commenterLogin, args.pullRequestAuthorLogin)) return false; - const rolesWithoutMiner = actorRoles({ ...args, minerStatus: undefined }); - return !rolesWithoutMiner.some((role) => allowedRoles.includes(role)); -} - -export function evaluateCommandAuthorization(args: { - policy?: RepositoryCommandAuthorizationPolicy | null | undefined; - commandName: string; - commenterLogin?: string | null | undefined; - commenterAssociation?: string | null | undefined; - pullRequestAuthorLogin?: string | null | undefined; - minerStatus?: "confirmed" | "not_found" | "unavailable" | undefined; - /** #5084: required (must be `"hold"`) for a bare `pr_author` match to actually authorize a command in - * {@link PR_AUTHOR_RATE_LIMITED_COMMANDS} (currently just `chat`) -- unset/`"off"` denies exactly as if - * `pr_author` weren't in the allowed-roles list at all, so a repo that hasn't turned on rate limiting - * never grants contributor chat access no matter what `chat`'s configured roles say. */ - commandRateLimitPolicy?: "off" | "hold" | undefined; - /** #5092: ALSO required (must be `true`) for a bare `pr_author` match to authorize a command in - * {@link PR_AUTHOR_RATE_LIMITED_COMMANDS} -- the per-PR rate-limit counter (`repoFullName#issueNumber#command`) - * never resets or checks PR state, so without this a contributor could keep a fresh allowance forever by - * reopening/reusing a closed PR or spamming cheap draft PRs. Caller-computed (e.g. `pr.state === "open" && - * !pr.isDraft`) so this function doesn't need to know GitHub's own state-string conventions. Unset/`false` - * denies exactly like a missing rate-limit policy -- maintainers/collaborators are unaffected regardless - * (this bounds the less-trusted pr_author tier, not already-trusted roles). */ - pullRequestOpenAndNotDraft?: boolean | undefined; -}): CommandAuthorizationDecision { - const allowedRoles = commandAuthorizationAllowedRoles(args.policy, args.commandName); - const roles = actorRoles(args); - const matchedRole = roles.find((role) => allowedRoles.includes(role)) ?? null; - const prAuthorGatedCommand = matchedRole === "pr_author" && PR_AUTHOR_RATE_LIMITED_COMMANDS.has(normalizeCommandName(args.commandName)); - if (prAuthorGatedCommand && args.commandRateLimitPolicy !== "hold") { - return { authorized: false, reason: "pr_author_requires_rate_limiting", actorKind: "author", matchedRole: null, allowedRoles }; - } - if (prAuthorGatedCommand && args.pullRequestOpenAndNotDraft !== true) { - return { authorized: false, reason: "pr_author_requires_open_pr", actorKind: "author", matchedRole: null, allowedRoles }; - } - if (matchedRole) { - return { - authorized: true, - reason: authorizationReason(matchedRole), - actorKind: matchedRole === "maintainer" || matchedRole === "collaborator" ? "maintainer" : "author", - matchedRole, - allowedRoles, - }; - } - const ownPrAuthor = isSameLogin(args.commenterLogin, args.pullRequestAuthorLogin); - if (ownPrAuthor && allowedRoles.includes("confirmed_miner")) { - return { - authorized: false, - reason: args.minerStatus === "unavailable" || !args.minerStatus ? "miner_detection_unavailable" : "pr_author_not_confirmed_miner", - actorKind: "author", - matchedRole: null, - allowedRoles, - }; - } - if (ownPrAuthor && MAINTAINER_ONLY_DEFAULT_COMMANDS.has(normalizeCommandName(args.commandName)) && allowedRoles.every((role) => role === "maintainer" || role === "collaborator")) { - return { authorized: false, reason: "maintainer_command_requires_maintainer", actorKind: "author", matchedRole: null, allowedRoles }; - } - return { - authorized: false, - reason: ownPrAuthor ? "command_policy_denied" : "not_maintainer_or_pr_author", - actorKind: ownPrAuthor ? "author" : "none", - matchedRole: null, - allowedRoles, - }; -} - -export function summarizeCommandAuthorizationPolicy(policy: RepositoryCommandAuthorizationPolicy | null | undefined): { - defaultAllowed: CommandAuthorizationRole[]; - commandOverrides: Array<{ command: string; allowedRoles: CommandAuthorizationRole[] }>; -} { - const normalized = normalizeCommandAuthorizationPolicy(policy).policy; - return { - defaultAllowed: normalized.default, - commandOverrides: Object.entries(normalized.commands) - .map(([command, allowedRoles]) => ({ command, allowedRoles })) - .sort((left, right) => left.command.localeCompare(right.command)), - }; -} - -function normalizeCommandRoleList(commandName: string, roles: CommandAuthorizationRole[], warnings: string[]): CommandAuthorizationRole[] { - if (!MAINTAINER_ONLY_DEFAULT_COMMANDS.has(commandName)) return roles; - - // #5084: a role also survives the clamp if it's explicitly part of THIS command's own shipped default - // (chat's own default now includes pr_author) -- so a maintainer restating or narrowing a command's own - // default via yml never gets silently mangled, while every OTHER maintainer-only command whose own default - // excludes pr_author still can't have it added via override (this is a per-command union, not a blanket - // relaxation: the clamp still can't be conjured up on generate-tests/pause/etc.). - /* v8 ignore next -- defensive: MAINTAINER_ONLY_DEFAULT_COMMANDS is derived from these keys, so a maintainer-only command always resolves a default list. */ - const commandOwnDefaultRoles = DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands[commandName] ?? []; - const allowedClampRoles = new Set([...MAINTAINER_COMMAND_AUTHORIZATION_ROLES, ...commandOwnDefaultRoles]); - const maintainerRoles = roles.filter((role) => allowedClampRoles.has(role)); - if (maintainerRoles.length === roles.length) return roles; - - warnings.push(`Ignored author command authorization roles for maintainer-only command: ${commandName}.`); - if (maintainerRoles.length > 0) return dedupeRoles(maintainerRoles); - const defaultRoles = DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands[commandName]; - /* v8 ignore next -- defensive: MAINTAINER_ONLY_DEFAULT_COMMANDS is derived from these keys, so a maintainer-only command always resolves a default list. */ - return [...(defaultRoles ?? ["maintainer", "collaborator"])]; -} - -function actorRoles(args: { - commenterLogin?: string | null | undefined; - commenterAssociation?: string | null | undefined; - pullRequestAuthorLogin?: string | null | undefined; - minerStatus?: "confirmed" | "not_found" | "unavailable" | undefined; -}): CommandAuthorizationRole[] { - const roles: CommandAuthorizationRole[] = []; - if (args.commenterAssociation === "OWNER" || args.commenterAssociation === "MEMBER") roles.push("maintainer"); - if (args.commenterAssociation === "COLLABORATOR") roles.push("collaborator"); - if (isSameLogin(args.commenterLogin, args.pullRequestAuthorLogin)) { - roles.push("pr_author"); - if (args.minerStatus === "confirmed") roles.push("confirmed_miner"); - } - return roles; -} - -function normalizeRoleList(input: unknown, fallback: CommandAuthorizationRole[], label: string, warnings: string[]): CommandAuthorizationRole[] { - if (!Array.isArray(input)) { - if (input !== undefined) warnings.push(`commandAuthorization.${label} must be an array of roles; using fallback roles.`); - return dedupeRoles(fallback); - } - const roles = input.filter((role): role is CommandAuthorizationRole => { - const valid = typeof role === "string" && COMMAND_AUTHORIZATION_ROLES.has(role as CommandAuthorizationRole); - if (!valid) warnings.push(`Ignored invalid command authorization role for ${label}.`); - return valid; - }); - if (roles.length === 0) { - warnings.push(`commandAuthorization.${label} had no valid roles; using fallback roles.`); - return dedupeRoles(fallback); - } - return dedupeRoles(roles); -} - -function dedupeRoles(roles: CommandAuthorizationRole[]): CommandAuthorizationRole[] { - return [...new Set(roles)]; -} - -function clonePolicy(policy: RepositoryCommandAuthorizationPolicy): RepositoryCommandAuthorizationPolicy { - return { default: [...policy.default], commands: Object.fromEntries(Object.entries(policy.commands).map(([command, roles]) => [command, [...roles]])) }; -} - -function authorizationReason(role: CommandAuthorizationRole): string { - if (role === "maintainer") return "maintainer_invocation"; - if (role === "collaborator") return "collaborator_invocation"; - if (role === "confirmed_miner") return "confirmed_miner_pr_author"; - return "allowed_pr_author"; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isSameLogin(left: string | null | undefined, right: string | null | undefined): boolean { - return Boolean(left && right && left.toLowerCase() === right.toLowerCase()); -} +// command-authorization, converged onto @loopover/engine (#4879, extended by #6194). This src/ file was a +// hand-maintained twin of the engine copy; it is now a thin re-export shim so the single implementation lives at +// packages/loopover-engine/src/settings/command-authorization.ts (imported via relative source path, not the published +// package, to match this repo's existing engine-consumption convention — see src/signals/check-summary.ts). +export * from "../../packages/loopover-engine/src/settings/command-authorization"; diff --git a/src/settings/contributor-blacklist.ts b/src/settings/contributor-blacklist.ts index 9a8d884077..9d5e81467a 100644 --- a/src/settings/contributor-blacklist.ts +++ b/src/settings/contributor-blacklist.ts @@ -1,91 +1,5 @@ -// 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 (`.loopover.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 -// may carry private maintainer metadata, so public surfaces must not echo it. 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 `.loopover.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. Tolerates an absent list (treated as empty) - * so callers can pass the optional `settings.contributorBlacklist` directly. */ -export function findBlacklistEntry(login: string | null | undefined, entries: ContributorBlacklistEntry[] | undefined): 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[] | undefined): 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; -} +// contributor-blacklist, converged onto @loopover/engine (#4879, extended by #6194). This src/ file was a +// hand-maintained twin of the engine copy; it is now a thin re-export shim so the single implementation lives at +// packages/loopover-engine/src/settings/contributor-blacklist.ts (imported via relative source path, not the published +// package, to match this repo's existing engine-consumption convention — see src/signals/check-summary.ts). +export * from "../../packages/loopover-engine/src/settings/contributor-blacklist"; diff --git a/src/settings/pr-type-label.ts b/src/settings/pr-type-label.ts index 977c75bdee..1b0af75635 100644 --- a/src/settings/pr-type-label.ts +++ b/src/settings/pr-type-label.ts @@ -1,182 +1,5 @@ -// Neutral per-PR TYPE label (reviewbot src/core/auto-label.ts parity). The label CATEGORIES are a -// config-driven, open `category -> label name` map (#label-modularity) — `bug`/`feature`/`priority` are -// the built-in gittensor:* categories shipped as the DEFAULT config, not hardcoded engine assumptions: -// priority — ONLY when a linked/closing issue already carries the configured priority issue label -// (#priority-linked-issue-gate, `linkedIssueLabelPropagation`). Never inferred from title, -// changed files, AI output, or existing PR labels. -// feature — genuine NEW functionality only (conventional-commit `feat`/`feature`). -// bug — EVERYTHING ELSE: fix, test, docs, chore, refactor, perf, ci, build, style, revert. -// A self-hoster can register a bounded number of ADDITIONAL categories in `typeLabels` beyond these three (e.g. -// `security: "area:security"`) — an extra category is never chosen by title-classification (only bug/ -// feature are), only ever by a configured `linkedIssueLabelPropagation` mapping's `prLabel` (which can -// target ANY string, registered in `typeLabels` or not); registering it here just makes it participate -// in the mutual-exclusivity cleanup below, i.e. eligible for automatic removal when a PR's classification -// moves away from it. Public + neutral categorization (NOT the reputation signal). Review-time + -// independent of the gate / autonomy / dry-run (matches reviewbot, where auto-label runs at review -// start). Fail-safe. -import type { LinkedIssueLabelPropagationConfig, LinkedIssueLabelPropagationMapping, PrTypeLabelSet } from "../types"; - -export type { PrTypeLabelSet } from "../types"; - -/** The gittensor: namespace Gittensor itself uses -- an EXAMPLE default config, not an engine - * assumption (#label-modularity): a self-hoster's `typeLabels` fully replaces the category set these - * keys are drawn from. The built-in categories are mutually exclusive by default (see - * `resolvePrTypeLabel`'s `removeLabels`) unless a propagation mapping is explicitly additive. */ -export const DEFAULT_TYPE_LABELS: PrTypeLabelSet = { - bug: "gittensor:bug", - feature: "gittensor:feature", - priority: "gittensor:priority", -}; - -export const MAX_TYPE_LABEL_CATEGORIES = 32; -export const MAX_TYPE_LABEL_NAME_LENGTH = 50; - -const FEATURE_TITLE_ACTION_RE = /\b(add|adds|added|create|creates|created|enable|enables|enabled|implement|implements|implemented|integrate|integrates|integrated|introduce|introduces|introduced|launch|launches|launched|support|supports|supported|wire|wires|wired)\b/i; -const FEATURE_TITLE_DOWNGRADE_RE = /\b(avoid|block|bug|bugfix|cache|classify|classifies|classifying|cleanup|clean-up|clean up|detect|detects|detecting|docs?|fix|format|guard|lint|normalize|recognize|recognizes|recognizing|refactor|regression|rename|test|tests|testing|tighten|typo)\b/i; - -/** feature ONLY for substantial new functionality: a feat/feature prefix plus a concrete add/support/enable - * action, with small recognition/classification/cleanup-style work downgraded to bug/work. EVERYTHING else — - * fix, test, docs, chore, refactor, perf, ci, build, style, revert — is bug. */ -export function deriveKindFromTitle(title: string | undefined): "bug" | "feature" { - const normalized = (title ?? "").trim(); - const match = /^([a-zA-Z]+)/.exec(normalized); - const type = match?.[1]?.toLowerCase(); - if (type !== "feat" && type !== "feature") return "bug"; - const subject = normalized.replace(/^[a-zA-Z]+(?:\([^)]*\))?:?\s*/, ""); - if (!FEATURE_TITLE_ACTION_RE.test(subject)) return "bug"; - return FEATURE_TITLE_DOWNGRADE_RE.test(subject) ? "bug" : "feature"; -} - -/** Defaults-fill a per-repo `typeLabels` override (config-as-code), generic over an arbitrary set of - * categories (#label-modularity): every key of `DEFAULT_TYPE_LABELS` (the built-in bug/feature/ - * priority categories) is taken independently from `input` when it is a non-empty string, else falls - * back to the corresponding built-in default — so a repo can override just one built-in label name - * (e.g. only `priority`) and keep the others default. Any EXTRA key present in `input` beyond the - * built-in set (a self-hoster's own custom category, e.g. `security`) is included verbatim when - * valid, up to `MAX_TYPE_LABEL_CATEGORIES` total categories and GitHub's 50-character label-name - * limit; there is no built-in default for it to fall back to, so an invalid extra-category value is - * dropped entirely (warned, not defaulted) rather than silently defaulted. A non-object input yields - * the full default set; omitted is normal (no warning), present-but-wrong-shaped warns. An input that - * IS a valid object but has zero own keys (`{}`) also yields the full default set here — this - * function only ever defaults-fills or validates a COMPLETE settings value (the DB-persisted set, or - * a from-scratch construction); `resolveEffectiveSettings` (focus-manifest.ts) is what gives a - * manifest's *literal* `typeLabels: {}` its own distinct "deliberately zero categories" meaning, - * since collapsing that here would also flip every legacy `type_labels_json = '{}'` DB row (the SQL - * column's own default, predating any explicit customization) from full defaults to zero labels — - * the exact behavior change #priority-linked-issue-gate's migration promised existing repos would - * never see. Mirrors `normalizeCommandAuthorizationPolicy`'s defaults-fill pattern - * (`src/settings/command-authorization.ts`). */ -export function normalizeTypeLabelSet(input: unknown, warnings: string[]): PrTypeLabelSet { - if (input === undefined) return { ...DEFAULT_TYPE_LABELS }; - if (typeof input !== "object" || input === null || Array.isArray(input)) { - warnings.push("settings.typeLabels must be an object; using default label names."); - return { ...DEFAULT_TYPE_LABELS }; - } - const record = input as Record; - const keys = new Set([...Object.keys(DEFAULT_TYPE_LABELS), ...Object.keys(record)]); - const result: PrTypeLabelSet = {}; - for (const key of keys) { - const value = record[key]; - const wouldAddCategory = result[key] === undefined; - if (wouldAddCategory && Object.keys(result).length >= MAX_TYPE_LABEL_CATEGORIES) { - if (value !== undefined) warnings.push(`settings.typeLabels has more than ${MAX_TYPE_LABEL_CATEGORIES} categories; ignoring ${key}.`); - continue; - } - const builtInDefault: string | undefined = DEFAULT_TYPE_LABELS[key]; - if (typeof value === "string" && value.trim().length > 0 && value.trim().length <= MAX_TYPE_LABEL_NAME_LENGTH) { - result[key] = value.trim(); - continue; - } - if (value !== undefined) { - const reason = typeof value === "string" && value.trim().length > MAX_TYPE_LABEL_NAME_LENGTH ? `a non-empty string no longer than ${MAX_TYPE_LABEL_NAME_LENGTH} characters` : "a non-empty string"; - warnings.push( - builtInDefault !== undefined - ? `settings.typeLabels.${key} must be ${reason}; using the default "${builtInDefault}".` - : `settings.typeLabels.${key} must be ${reason}; ignoring it.`, - ); - } - // Reached for BOTH an invalid present value and an absent one -- a built-in category (bug/feature/ - // priority) always has a default to fall back to; an unknown custom category does not, so it is - // dropped entirely (warned above when it was present-but-invalid, silently absent when never named). - if (builtInDefault !== undefined) result[key] = builtInDefault; - } - return result; -} - -/** The pure decision `resolvePrTypeLabel` returns: which label(s) to apply, which configured - * type-label-set members to remove for mutual exclusivity, and why. */ -export type PrTypeLabelDecision = { - applyLabels: string[]; - removeLabels: string[]; - source: "propagation_exclusive" | "propagation_additive" | "title"; -}; - -/** - * Resolve the TYPE label decision for a PR. - * 1. Linked-issue label PROPAGATION (config-driven, #priority-linked-issue-gate): when enabled, the - * LAST configured EXCLUSIVE mapping whose `issueLabel` appears (case-insensitively) among the - * ALREADY-FETCHED `linkedIssueLabels` wins (#5385 -- declare exclusive mappings in ascending - * precedence order). This is the ONLY way a label like `gittensor:priority` - * can ever be chosen — this function does no I/O and never infers it from title, changed files, - * AI output, or PR labels; the caller must fetch `linkedIssueLabels` itself (see - * `fetchLinkedIssueLabelsForPropagation` in `review/linked-issue-label-propagation-fetch.ts`). - * - `removeOtherTypeLabels: true` (exclusive) — the mapped label REPLACES the type label, - * exactly like today's bug/feature/priority classification (used for `gittensor:priority`). - * - `removeOtherTypeLabels: false` (additive) — the mapped label is applied ALONGSIDE the - * normal title-based bug/feature label, which is left untouched (e.g. a generic - * `customer:vip` → `triage:vip` triage marker that has nothing to do with bug/feature/priority). - * 2. Otherwise, feature (feat/feature) / bug (everything else) by the conventional-commit title prefix - * -- ONLY when `labels` actually has a name registered for that built-in category; a configured set - * that omits `bug`/`feature` entirely (a self-hoster who only wants custom, propagation-driven - * categories, or an explicit `typeLabels: {}` resolved to zero categories) applies nothing for that - * branch rather than inventing a label name (#label-modularity). - * `removeLabels` is always "every member of the configured type-label set that isn't one of - * `applyLabels`" — generic and total over however many categories are configured, and safe even if a - * misconfigured additive mapping's `prLabel` happens to collide with a type-label-set name (it is - * excluded from removal since it is also being applied). Pure + total. - */ -export function resolvePrTypeLabel(input: { - title: string | undefined; - linkedIssueLabels?: string[] | undefined; - labels?: PrTypeLabelSet | undefined; - propagation?: LinkedIssueLabelPropagationConfig | undefined; -}): PrTypeLabelDecision { - const labels = input.labels ?? DEFAULT_TYPE_LABELS; - const isRealLabel = (label: string | undefined): label is string => typeof label === "string" && label.length > 0; - const typeLabelSet = Object.values(labels).filter(isRealLabel).filter((label) => label.length <= MAX_TYPE_LABEL_NAME_LENGTH).slice(0, MAX_TYPE_LABEL_CATEGORIES); - const titleLabel: string | undefined = labels[deriveKindFromTitle(input.title)]; - const decide = (applyLabels: ReadonlyArray, source: PrTypeLabelDecision["source"]): PrTypeLabelDecision => { - const apply = [...new Set(applyLabels.filter(isRealLabel))]; - return { applyLabels: apply, removeLabels: typeLabelSet.filter((label) => !apply.includes(label)), source }; - }; - - if (input.propagation?.enabled) { - const wanted = new Set((input.linkedIssueLabels ?? []).map((label) => label.toLowerCase())); - // Collect EVERY mapping the linked issue's labels satisfy, not just the first. An exclusive mapping - // (removeOtherTypeLabels: true -- e.g. bug/feature, genuinely mutually-exclusive categories) lets the - // LAST-configured match win, not the first (#5385 fix -- was first-match-wins, which meant a linked issue - // carrying BOTH gittensor:bug and gittensor:feature always resolved to bug, the lower-value label, purely - // because bug is declared before feature in `.loopover.yml`). Operators must declare exclusive mappings - // in ASCENDING precedence order (lowest-value category first, e.g. bug then feature) so the last match - // encountered while iterating is the highest-precedence one that actually applies -- this mirrors the - // repo's own default mapping order, which is already bug/feature/priority (ascending multiplier value). - // An additive mapping (e.g. priority -- a maintainer-hand-picked reward tag that coexists WITH whichever - // type already applies, not a type of its own) must compose with that winner instead of being skipped just - // because an earlier mapping in the array already matched. Before the original #priority-linked-issue-gate - // composition fix, an additive match was unreachable whenever the SAME linked issue also carried a label an - // earlier (exclusive) mapping matched -- the overwhelmingly common case for gittensor:priority, which is - // applied ALONGSIDE gittensor:bug/gittensor:feature on the issue, never instead of it. - let exclusiveMatch: LinkedIssueLabelPropagationMapping | undefined; - const additiveMatches: LinkedIssueLabelPropagationMapping[] = []; - for (const mapping of input.propagation.mappings) { - if (!wanted.has(mapping.issueLabel.toLowerCase())) continue; - if (mapping.removeOtherTypeLabels) exclusiveMatch = mapping; - else additiveMatches.push(mapping); - } - if (exclusiveMatch || additiveMatches.length > 0) { - const applyLabels = [exclusiveMatch ? exclusiveMatch.prLabel : titleLabel, ...additiveMatches.map((mapping) => mapping.prLabel)]; - return decide(applyLabels, exclusiveMatch ? "propagation_exclusive" : "propagation_additive"); - } - } - return decide([titleLabel], "title"); -} +// pr-type-label, converged onto @loopover/engine (#4879, extended by #6194). This src/ file was a hand-maintained twin +// of the engine copy; it is now a thin re-export shim so the single implementation lives at +// packages/loopover-engine/src/settings/pr-type-label.ts (imported via relative source path, not the published package, +// to match this repo's existing engine-consumption convention — see src/signals/check-summary.ts). +export * from "../../packages/loopover-engine/src/settings/pr-type-label"; diff --git a/test/unit/check-engine-parity-script.test.ts b/test/unit/check-engine-parity-script.test.ts index cbee0c4677..c72f993e92 100644 --- a/test/unit/check-engine-parity-script.test.ts +++ b/test/unit/check-engine-parity-script.test.ts @@ -92,7 +92,11 @@ describe("check-engine-parity script", () => { it("discovers real in-scope pairs in the repository (regression guard)", () => { const pairs = discoverEngineParityPairs({ root: process.cwd() }); - expect(pairs.length).toBeGreaterThanOrEqual(14); + // Floor tracks the count of still-hand-duplicated in-scope twins, minus a small margin so unrelated + // additions don't trip it while a broken scanner returning ~0 still does. #6194 converged the last four + // settings twins (autonomy/command-authorization/contributor-blacklist/pr-type-label) onto their engine + // shims, so the floor drops from 14 to 10 — the `.some()` structural checks below are the real guard. + expect(pairs.length).toBeGreaterThanOrEqual(10); expect(pairs.some((pair: EngineParityPair) => pair.fileName === "guardrail-config.ts")).toBe(true); expect(pairs.some((pair: EngineParityPair) => pair.fileName === "change-guardrail.ts")).toBe(true); expect(pairs.some((pair: EngineParityPair) => pair.fileName === "duplicate-winner.ts")).toBe(false);