diff --git a/.gittensory.yml b/.gittensory.yml index fc68bd55b5..87a4992086 100644 --- a/.gittensory.yml +++ b/.gittensory.yml @@ -32,6 +32,17 @@ testExpectations: issueDiscoveryPolicy: discouraged +# Authoritative gate config, config-as-code (layered OVER dashboard repository settings: +# .gittensory.yml > DB settings > safe defaults). ONLY confirmed Gittensor contributors are ever +# hard-blocked (see PR #644); these fields only choose what the gate does, not who it applies to. +gate: + # enabled: false # set false to disable the gate from config (turning it on is a dashboard setting) + linkedIssue: advisory # block | advisory | off — issues aren't always available; advise, don't block + duplicates: block # block | advisory | off — block obvious duplicate PRs + readiness: + mode: advisory # block | advisory | off — readiness-score floor + minScore: 60 + publicNotes: - Prefer backend Workers, MCP, GitHub App, registry, and scoring work when scope allows. - Focused control-panel UI changes are welcome when they use live API data or honest empty/error states and tie to safety, release readiness, or operator-facing analytics. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61a15bdaa1..ce58d93371 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -189,6 +189,29 @@ Public GitHub surfaces: - Keep public comments advisory, sanitized, and low-noise. - Keep labels limited to configured labels for officially confirmed Gittensor miner PRs. - Never publish private reviewability, scoring, wallet, hotkey, or reward/risk context. +- The Gittensory Gate blocks **only confirmed Gittensor contributors**; every other author (and any + app/infra state) resolves to a neutral, non-blocking gate. Adding a blocker must keep it + confirmed-contributor-gated through `evaluateGateCheck`. + +Config as code (`.gittensory.yml`) — every repository setting is controllable from the config file: + +- **`settings:`** is a partial of the repository settings: any behaviour a maintainer can toggle in the + dashboard can be set here as code — `commentMode`, `publicAudienceMode`, `publicSurface`, `checkRunMode`, + `gateCheckMode`, the gate-blocker modes, `autoLabelEnabled`, `gittensorLabel`, `requireLinkedIssue`, + `backfillEnabled`, etc. +- **`gate:`** is a friendly typed alias for the gate subset — `enabled` (on/off), `linkedIssue`, + `duplicates`, `readiness: { mode, minScore }` (each `off | advisory | block`). +- **`review:`** customizes the public review-panel CONTENT: `footer: { text }` (custom lead copy — the + Gittensor register link + attribution are always appended), `note` (a custom intro line), and + `fields: { : false }` to show/hide individual panel rows (`linkedIssue`, `relatedWork`, `reviewLoad`, + `validationEvidence`, `openPrQueue`, `contributorContext`, `gateResult`). Maintainer text that fails the + public-safe filter (reward/score/wallet/hotkey/etc.) is dropped, never published. +- Precedence: `.gittensory.yml` `gate:` > `.gittensory.yml` `settings:` > dashboard repository settings > + safe defaults; unset fields fall back to the next layer. The committed root `.gittensory.yml` is the + worked example. Resolved once in `resolveRepositorySettings`, so the whole app honours the file. +- The config chooses **what** gittensory does (gate on/off, blockers, comments, labels, surface, panel + content); it never changes **who** can be blocked — only confirmed Gittensor contributors are ever + hard-blocked, and the footer's Gittensor attribution/register link always remains. ## Commit And PR Titles diff --git a/src/config/gittensory-repo-focus-manifest.ts b/src/config/gittensory-repo-focus-manifest.ts index 1c548cf4c2..f6beb1699e 100644 --- a/src/config/gittensory-repo-focus-manifest.ts +++ b/src/config/gittensory-repo-focus-manifest.ts @@ -36,6 +36,17 @@ testExpectations: issueDiscoveryPolicy: discouraged +# Authoritative gate config, config-as-code (layered OVER dashboard repository settings: +# .gittensory.yml > DB settings > safe defaults). ONLY confirmed Gittensor contributors are ever +# hard-blocked (see PR #644); these fields only choose what the gate does, not who it applies to. +gate: + # enabled: false # set false to disable the gate from config (turning it on is a dashboard setting) + linkedIssue: advisory # block | advisory | off — issues aren't always available; advise, don't block + duplicates: block # block | advisory | off — block obvious duplicate PRs + readiness: + mode: advisory # block | advisory | off — readiness-score floor + minScore: 60 + publicNotes: - Prefer backend Workers, MCP, GitHub App, registry, and scoring work when scope allows. - Focused control-panel UI changes are welcome when they use live API data or honest empty/error states and tie to safety, release readiness, or operator-facing analytics. diff --git a/src/github/footer.ts b/src/github/footer.ts index 60c2d50686..f58a5af956 100644 --- a/src/github/footer.ts +++ b/src/github/footer.ts @@ -25,8 +25,18 @@ export function gittensorRepoEarnUrl(repoFullName: string): string { * invite and anyone viewing a registered contributor's PR sees it too. The registered/non-registered * distinction lives in the review BODY (full panel vs. minimal), not here. * Uses only "earn" wording — never reward/payout/score (forbidden in public comments). */ -export function gittensoryFooter(opts: { earnUrl?: string | undefined } = {}): string { +export function gittensoryFooter(opts: { earnUrl?: string | undefined; customText?: string | undefined } = {}): string { const earnUrl = opts.earnUrl ?? GITTENSOR_HOME_URL; + // Maintainer-customized footer (via `.gittensory.yml review.footer.text`): the maintainer's public-safe + // lead replaces the default CTA copy, but the Gittensor register link + Gittensory attribution are + // ALWAYS appended — the growth surface is preserved regardless of customization. + if (opts.customText) { + return [ + opts.customText, + "", + `[Gittensor](${GITTENSOR_HOME_URL}) lets GitHub contributors earn for the work they already do — [register to start earning →](${earnUrl}). Checked by [Gittensory](${GITTENSORY_SITE_URL}).`, + ].join("\n"); + } return [ `💰 **Earn for open-source contributions like this.** [Gittensor](${GITTENSOR_HOME_URL}) lets GitHub contributors earn for the work they already do — [register to start earning →](${earnUrl}).`, "", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 5117b4ee7c..368f0d55c9 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -129,6 +129,8 @@ import { unionScopedOverlapClusters, } from "../signals/engine"; import { decidePublicSurface } from "../signals/settings-preview"; +import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; +import { resolveEffectiveSettings } from "../signals/focus-manifest"; import type { LocalBranchAnalysisInput } from "../signals/local-branch"; import type { ContributorEvidenceRecord, GitHubWebhookPayload, JobMessage, JsonValue, PullRequestRecord, RepositorySettings } from "../types"; import { sha256Hex } from "../utils/crypto"; @@ -695,7 +697,7 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str const pr = await upsertPullRequestFromGitHub(env, repoFullName, payload.pull_request); const [repo, settings, otherOpenPullRequests] = await Promise.all([ getRepository(env, repoFullName), - getRepositorySettings(env, repoFullName), + resolveRepositorySettings(env, repoFullName), listOtherOpenPullRequests(env, repoFullName, pr.number), ]); const advisory = buildPullRequestAdvisory(repo, pr, { @@ -780,7 +782,10 @@ function shouldProcessPullRequestPublicSurface(action: string | undefined): bool return PR_PUBLIC_SURFACE_ACTIONS.has(action ?? "") || PR_GATE_CLOSED_ACTIONS.has(action ?? ""); } -function gateCheckPolicy(settings: RepositorySettings, readinessScore?: number | null, confirmedContributor?: boolean) { +export function gateCheckPolicy(settings: RepositorySettings, readinessScore?: number | null, confirmedContributor?: boolean) { + // `settings` is already the EFFECTIVE config (`.gittensory.yml` > DB > defaults), resolved upstream by + // resolveRepositorySettings, so the blocker modes here reflect the repo's config file directly. + // confirmedContributor governs WHO can be blocked, downstream in evaluateGateCheck. return { linkedIssueGateMode: settings.linkedIssueGateMode, duplicatePrGateMode: settings.duplicatePrGateMode, @@ -791,6 +796,16 @@ function gateCheckPolicy(settings: RepositorySettings, readinessScore?: number | }; } +/** + * Effective repository settings for webhook handling: the DB-backed settings overlaid with the repo's + * `.gittensory.yml` (config-as-code). This single resolver is why EVERYTHING — gate on/off, all blocker + * modes, comments, labels, surface, audience — is controllable from the repo's config file. + */ +async function resolveRepositorySettings(env: Env, repoFullName: string): Promise { + const [dbSettings, manifest] = await Promise.all([getRepositorySettings(env, repoFullName), loadRepoFocusManifest(env, repoFullName)]); + return resolveEffectiveSettings(dbSettings, manifest); +} + function linkedIssueDuplicatePullRequestsForGate(pr: PullRequestRecord, pullRequests: PullRequestRecord[]): number[] { const linkedIssues = new Set(pr.linkedIssues); if (linkedIssues.size === 0) return []; @@ -850,6 +865,9 @@ async function maybePublishPrPublicSurface( webhook: { deliveryId: string; authorType?: string | undefined; action?: string | undefined }, ): Promise { const author = pr.authorLogin ?? null; + // `settings` is the EFFECTIVE config (`.gittensory.yml` > DB > defaults), resolved by the caller via + // resolveRepositorySettings — so gate on/off and every blocker mode already reflect the repo's config + // file. The gate only chooses what to do; confirmedContributor governs WHO can be blocked. const gateEnabled = settings.gateCheckMode === "enabled" && Boolean(advisory.headSha); // Cheap, network-free skip checks (also avoids the miner lookup when it would be wasted). const prelim = decidePublicSurface({ @@ -967,8 +985,7 @@ async function maybePublishPrPublicSurface( // detection) gets a neutral, non-blocking gate. Gate-only runs still verify confirmation before // evaluating blockers so confirmed contributors cannot bypass a required Gate check. const confirmedContributor = official?.status === "confirmed"; - const gateEvaluation = - settings.gateCheckMode === "enabled" ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor)) : undefined; + const gateEvaluation = gateEnabled ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor)) : undefined; if (gateEnabled) { const gateCheckResult = await createOrUpdateGateCheckRun( env, @@ -1030,7 +1047,10 @@ async function maybePublishPrPublicSurface( } if (decision.willComment) { - const commentArgs = { repo, pr, profile, detection, queueHealth, collisions, preflight, settings, gate: gateEvaluation }; + // Maintainer review-content overrides from `.gittensory.yml` (footer text, row toggles, intro note). + // Cached, so this is a DB read after the settings resolution already loaded the manifest. + const reviewConfig = (await loadRepoFocusManifest(env, repoFullName)).review; + const commentArgs = { repo, pr, profile, detection, queueHealth, collisions, preflight, settings, gate: gateEvaluation, review: reviewConfig }; const deterministicBody = buildPublicPrIntelligenceComment(commentArgs); try { await createOrUpdatePrIntelligenceComment(env, installationId, repoFullName, pr.number, deterministicBody); @@ -1161,7 +1181,7 @@ async function maybeProcessPrPanelRetrigger(env: Env, deliveryId: string, payloa await recordPrPanelRetriggerSkip(env, deliveryId, repoFullName, targetKey, actor, "missing_repo_pr_or_installation"); return true; } - const [pr, settings] = await Promise.all([getPullRequest(env, repoFullName, issue.number), getRepositorySettings(env, repoFullName)]); + const [pr, settings] = await Promise.all([getPullRequest(env, repoFullName, issue.number), resolveRepositorySettings(env, repoFullName)]); if (!pr) { await recordPrPanelRetriggerSkip(env, deliveryId, repoFullName, targetKey, actor, "cached_pr_missing"); return true; @@ -1352,7 +1372,7 @@ async function maybeProcessGittensoryMentionCommand(env: Env, deliveryId: string return true; } - const [repo, cachedPullRequest, settings] = await Promise.all([getRepository(env, repoFullName), getPullRequest(env, repoFullName, issue.number), getRepositorySettings(env, repoFullName)]); + const [repo, cachedPullRequest, settings] = await Promise.all([getRepository(env, repoFullName), getPullRequest(env, repoFullName, issue.number), resolveRepositorySettings(env, repoFullName)]); const pullRequestAuthor = cachedPullRequest?.authorLogin ?? issue.user?.login ?? null; const needsMinerDetection = commandAuthorizationNeedsMinerDetection({ policy: settings.commandAuthorization, diff --git a/src/signals/engine.ts b/src/signals/engine.ts index f0ddd5050c..f25eb81afd 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -21,6 +21,7 @@ import type { } from "../types"; import type { PublicContributorProfile } from "../github/public"; import { gittensoryFooter, gittensorRepoEarnUrl } from "../github/footer"; +import type { FocusManifestReviewConfig, ReviewFieldKey } from "./focus-manifest"; import type { GittensorContributorSnapshot } from "../gittensor/api"; import { nowIso } from "../utils/json"; import { sanitizePublicComment } from "../queue-intelligence"; @@ -3857,6 +3858,7 @@ export function buildPublicPrIntelligenceComment(args: { preflight: PreflightResult; settings: RepositorySettings; gate?: PublicPrPanelGateEvaluation | undefined; + review?: FocusManifestReviewConfig | undefined; }): string { const publicFindings = args.preflight.findings .filter((finding) => finding.severity !== "critical") @@ -3935,22 +3937,21 @@ export function buildPublicPrIntelligenceComment(args: { const changeScopeComponent = readinessByKey.get("change_scope"); const queueComponent = readinessByKey.get("queue_pressure"); const contributorContext = contributorContextPanelResult(args.pr, args.profile, args.detection, confirmedMiner); - const rows: Array<[string, string, string, string]> = [ - [ - "Linked issue", - linkedIssueResult.result, - linkedIssueResult.evidence, - linkedIssueResult.action, - ], - ["Related work", relatedWorkResult.result, relatedWorkResult.evidence, relatedWorkResult.action], + // Each row carries a stable key so a maintainer can show/hide it from `.gittensory.yml review.fields` + // (default: shown). Hiding a row is cosmetic — the underlying signal/gate still functions. + const allRows: Array<{ key: ReviewFieldKey; cells: [string, string, string, string] }> = [ + { key: "linkedIssue", cells: ["Linked issue", linkedIssueResult.result, linkedIssueResult.evidence, linkedIssueResult.action] }, + { key: "relatedWork", cells: ["Related work", relatedWorkResult.result, relatedWorkResult.evidence, relatedWorkResult.action] }, /* v8 ignore start -- Readiness components are built as a fixed key set; fallbacks guard future partial score shapes. */ - ["Review load", scoreResultIcon(changeScopeComponent), changeScopeComponent?.evidence ?? "No public scope metadata found.", changeScopeComponent?.action ?? "No action."], - ["Validation evidence", scoreResultIcon(validationComponent), validationComponent?.evidence ?? "No validation signal found.", validationComponent?.action ?? "Add validation note."], - ["Open PR queue", scoreResultIcon(queueComponent), queueComponent?.evidence ?? "Open PR queue unavailable.", queueComponent?.action ?? "No action."], + { key: "reviewLoad", cells: ["Review load", scoreResultIcon(changeScopeComponent), changeScopeComponent?.evidence ?? "No public scope metadata found.", changeScopeComponent?.action ?? "No action."] }, + { key: "validationEvidence", cells: ["Validation evidence", scoreResultIcon(validationComponent), validationComponent?.evidence ?? "No validation signal found.", validationComponent?.action ?? "Add validation note."] }, + { key: "openPrQueue", cells: ["Open PR queue", scoreResultIcon(queueComponent), queueComponent?.evidence ?? "Open PR queue unavailable.", queueComponent?.action ?? "No action."] }, /* v8 ignore stop */ - ["Contributor context", contributorContext.result, contributorContext.evidence, contributorContext.action], - ["Gate result", gateStatus(gateEnabled, gateConclusion), gateEnabled ? gateAction(gateConclusion) : "Advisory only.", gateEnabled ? gateNextAction(gateConclusion) : "No action."], + { key: "contributorContext", cells: ["Contributor context", contributorContext.result, contributorContext.evidence, contributorContext.action] }, + { key: "gateResult", cells: ["Gate result", gateStatus(gateEnabled, gateConclusion), gateEnabled ? gateAction(gateConclusion) : "Advisory only.", gateEnabled ? gateNextAction(gateConclusion) : "No action."] }, ]; + const reviewFields = args.review?.fields; + const rows: Array<[string, string, string, string]> = allRows.filter((row) => reviewFields?.[row.key] !== false).map((row) => row.cells); const overlapDetails = relatedWorkDetails(args.pr, scopedOverlapClusters); const maintainerNotes = publicFindings.length > 0 @@ -3959,7 +3960,9 @@ export function buildPublicPrIntelligenceComment(args: { // Always-on earn CTA — a permanent, free marketing surface on every reviewed PR. For a registered // repo the CTA points at this repo's public Gittensor miner page (social proof for THIS repo + a // path to register); for an unregistered repo it falls back to the general Gittensor home URL. - const footer = gittensoryFooter({ earnUrl: footerEarnUrl(args.repo, args.pr.repoFullName) }); + // The earn CTA stays a permanent marketing surface; `.gittensory.yml review.footer.text` can replace + // the lead copy (already public-safe-validated) but the Gittensor register link + attribution remain. + const footer = gittensoryFooter({ earnUrl: footerEarnUrl(args.repo, args.pr.repoFullName), customText: args.review?.footerText ?? undefined }); return [ "", "", @@ -3967,6 +3970,8 @@ export function buildPublicPrIntelligenceComment(args: { `[!${alert}]`, `## ${panelTitle}`, panelSummary, + // Optional maintainer intro note (public-safe-validated at parse time; re-sanitized here). + ...(args.review?.note ? ["", sanitizePanelText(args.review.note)] : []), "", `**Readiness score: ${readiness.total}/100**`, "", @@ -4023,7 +4028,7 @@ export function buildPublicPrIntelligenceComment(args: { * analysis is for registered Gittensor contributors, so we skip the panel and post a brief welcome * + earn invite; the always-on footer CTA does the conversion. Carries the same panel marker so it * updates in place if the author later registers (the full panel then replaces it). */ -function buildMinimalInviteComment(args: { repo: RepositoryRecord | null; pr: PullRequestRecord }): string { +function buildMinimalInviteComment(args: { repo: RepositoryRecord | null; pr: PullRequestRecord; review?: FocusManifestReviewConfig | undefined }): string { return [ "", "", @@ -4034,7 +4039,7 @@ function buildMinimalInviteComment(args: { repo: RepositoryRecord | null; pr: Pu ]), "", "---", - gittensoryFooter({ earnUrl: footerEarnUrl(args.repo, args.pr.repoFullName) }), + gittensoryFooter({ earnUrl: footerEarnUrl(args.repo, args.pr.repoFullName), customText: args.review?.footerText ?? undefined }), ].join("\n"); } diff --git a/src/signals/focus-manifest-loader.ts b/src/signals/focus-manifest-loader.ts index 2d2d0dacac..7cab747b21 100644 --- a/src/signals/focus-manifest-loader.ts +++ b/src/signals/focus-manifest-loader.ts @@ -1,7 +1,7 @@ import { listSignalSnapshots, persistSignalSnapshot } from "../db/repositories"; import type { JsonValue } from "../types"; import { nowIso } from "../utils/json"; -import { MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, type FocusManifest, type FocusManifestSource } from "./focus-manifest"; +import { gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, reviewConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource } from "./focus-manifest"; import { GITTENSORY_REPO_FOCUS_MANIFEST_YAML, resolveGittensorySelfRepoFullName } from "../config/gittensory-repo-focus-manifest"; export const REPO_FOCUS_MANIFEST_SIGNAL = "repo-focus-manifest"; @@ -72,9 +72,10 @@ export async function loadRepoFocusManifest( } catch { manifest = parseFocusManifest(null); } - if (manifest.present) { - await persistRepoFocusManifest(env, repoFullName, manifest); - } + // Persist even an ABSENT manifest (negative cache): effective settings are resolved from + // `.gittensory.yml` on every webhook, so a repo without one must not re-fetch the raw file each time. + // The TTL still refreshes it, so a newly-added manifest is picked up on the next window. + await persistRepoFocusManifest(env, repoFullName, manifest); return manifest; } @@ -179,6 +180,9 @@ function manifestToJson(manifest: FocusManifest): Record { issueDiscoveryPolicy: manifest.issueDiscoveryPolicy, maintainerNotes: manifest.maintainerNotes, publicNotes: manifest.publicNotes, + gate: gateConfigToJson(manifest.gate), + settings: settingsOverrideToJson(manifest.settings), + review: reviewConfigToJson(manifest.review), }; } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index f5d44e9d0c..3ef6889da5 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -1,10 +1,77 @@ import { parse as parseYaml } from "yaml"; -import type { JsonValue } from "../types"; +import type { GateRuleMode, JsonValue, RepositorySettings } from "../types"; export type FocusManifestSource = "repo_file" | "api_record" | "none"; export type FocusManifestLinkedIssuePolicy = "required" | "preferred" | "optional"; export type FocusManifestIssueDiscoveryPolicy = "encouraged" | "neutral" | "discouraged"; +/** + * Maintainer-authored gate configuration declared as code in `.gittensory.yml` under `gate:`. Each + * field is `null` when the maintainer did not set it, so the resolver can layer the manifest OVER the + * DB-backed RepositorySettings (manifest > DB > safe defaults) without clobbering unset values. All + * of these flow through the SAME confirmed-contributor-gated `evaluateGateCheck` path — the manifest + * only chooses which deterministic blockers are active, never who can be blocked. Turning the gate + * itself on/off stays a repository setting (`gateCheckMode`); `.gittensory.yml gate:` refines the + * blocker policy of an already-enabled gate. + */ +export type FocusManifestGateConfig = { + present: boolean; + enabled: boolean | null; + linkedIssue: GateRuleMode | null; + duplicates: GateRuleMode | null; + readinessMode: GateRuleMode | null; + readinessMinScore: number | null; +}; + +/** + * Generic repository-settings override declared in `.gittensory.yml` under `settings:`. A partial of + * {@link RepositorySettings} — every behaviour a maintainer can toggle in the dashboard can be set here + * as code. Unset fields are omitted so the resolver layers it OVER the DB-backed settings + * (`.gittensory.yml` > dashboard settings > safe defaults). The friendly `gate:` block is a typed alias + * for the gate-related subset and wins over `settings:` for those fields. + */ +export type FocusManifestSettings = Partial< + Pick< + RepositorySettings, + | "commentMode" + | "publicAudienceMode" + | "publicSignalLevel" + | "checkRunMode" + | "checkRunDetailLevel" + | "gateCheckMode" + | "linkedIssueGateMode" + | "duplicatePrGateMode" + | "qualityGateMode" + | "qualityGateMinScore" + | "autoLabelEnabled" + | "gittensorLabel" + | "createMissingLabel" + | "publicSurface" + | "includeMaintainerAuthors" + | "requireLinkedIssue" + | "backfillEnabled" + | "privateTrustEnabled" + > +>; + +/** Field keys for the public review-panel rows a maintainer can show/hide via `review.fields`. */ +export const REVIEW_FIELD_KEYS = ["linkedIssue", "relatedWork", "reviewLoad", "validationEvidence", "openPrQueue", "contributorContext", "gateResult"] as const; +export type ReviewFieldKey = (typeof REVIEW_FIELD_KEYS)[number]; + +/** + * Maintainer overrides for the public review-panel CONTENT, declared under `review:`. Customizes the + * panel without changing what gittensory measures: a custom public-safe footer lead line, a custom intro + * note, and per-row show/hide toggles. The Gittensor attribution + register link is ALWAYS appended to + * the footer regardless (the growth surface is preserved); maintainer text that fails the public-safe + * filter is dropped, never published. + */ +export type FocusManifestReviewConfig = { + present: boolean; + footerText: string | null; + note: string | null; + fields: Partial>; +}; + /** * Normalized maintainer focus manifest. Repo owners declare which work areas are wanted, * blocked, or preferred so Gittensory guidance can explain why a path is encouraged or @@ -22,6 +89,9 @@ export type FocusManifest = { issueDiscoveryPolicy: FocusManifestIssueDiscoveryPolicy; maintainerNotes: string[]; publicNotes: string[]; + gate: FocusManifestGateConfig; + settings: FocusManifestSettings; + review: FocusManifestReviewConfig; warnings: string[]; }; @@ -60,6 +130,15 @@ const MAX_LIST_ITEMS = 200; const MAX_ITEM_LENGTH = 300; export const MAX_FOCUS_MANIFEST_BYTES = 64 * 1024; +const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { + present: false, + enabled: null, + linkedIssue: null, + duplicates: null, + readinessMode: null, + readinessMinScore: null, +}; + const EMPTY_MANIFEST: FocusManifest = { present: false, source: "none", @@ -71,6 +150,9 @@ const EMPTY_MANIFEST: FocusManifest = { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: [], + gate: { ...EMPTY_GATE_CONFIG }, + settings: {}, + review: { present: false, footerText: null, note: null, fields: {} }, warnings: [], }; @@ -83,7 +165,7 @@ export function isFocusManifestPublicSafe(text: string): boolean { } function emptyManifest(source: FocusManifestSource, warnings: string[] = []): FocusManifest { - return { ...EMPTY_MANIFEST, source, warnings }; + return { ...EMPTY_MANIFEST, source, warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {}, review: { present: false, footerText: null, note: null, fields: {} } }; } function normalizeStringList(value: JsonValue | undefined, field: string, warnings: string[]): string[] { @@ -131,6 +213,208 @@ function normalizeSource(raw: FocusManifestSource | undefined, value: JsonValue return normalizeEnum(value, "source", ["repo_file", "api_record", "none"], "api_record", warnings); } +function normalizeOptionalGateMode(value: JsonValue | undefined, field: string, warnings: string[]): GateRuleMode | null { + if (value === undefined || value === null) return null; + if (value === "off" || value === "advisory" || value === "block") return value; + warnings.push(`Manifest gate field "${field}" must be one of off, advisory, block; ignoring "${String(value)}".`); + return null; +} + +function normalizeOptionalBoolean(value: JsonValue | undefined, field: string, warnings: string[]): boolean | null { + if (value === undefined || value === null) return null; + if (typeof value === "boolean") return value; + warnings.push(`Manifest gate field "${field}" must be a boolean; ignoring a ${typeof value} value.`); + return null; +} + +function normalizeOptionalScore(value: JsonValue | undefined, field: string, warnings: string[]): number | null { + if (value === undefined || value === null) return null; + if (typeof value !== "number" || !Number.isFinite(value)) { + warnings.push(`Manifest gate field "${field}" must be a number between 0 and 100; ignoring it.`); + return null; + } + return Math.max(0, Math.min(100, Math.round(value))); +} + +/** + * Parse the optional `gate:` mapping. Every field stays `null` when unset so the resolver can layer + * this OVER DB settings without clobbering. A nested `readiness: { mode, minScore }` block is accepted. + */ +function parseGateConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestGateConfig { + if (value === undefined || value === null) return { ...EMPTY_GATE_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`Manifest field "gate" must be a mapping; ignoring it.`); + return { ...EMPTY_GATE_CONFIG }; + } + const record = value as Record; + const readiness = record.readiness; + const readinessRecord = readiness !== null && typeof readiness === "object" && !Array.isArray(readiness) ? (readiness as Record) : undefined; + if (readiness !== undefined && readiness !== null && readinessRecord === undefined) { + warnings.push(`Manifest gate field "gate.readiness" must be a mapping; ignoring it.`); + } + const gate: FocusManifestGateConfig = { + present: false, + enabled: normalizeOptionalBoolean(record.enabled, "gate.enabled", warnings), + linkedIssue: normalizeOptionalGateMode(record.linkedIssue, "gate.linkedIssue", warnings), + duplicates: normalizeOptionalGateMode(record.duplicates, "gate.duplicates", warnings), + readinessMode: normalizeOptionalGateMode(readinessRecord?.mode, "gate.readiness.mode", warnings), + readinessMinScore: normalizeOptionalScore(readinessRecord?.minScore, "gate.readiness.minScore", warnings), + }; + gate.present = + gate.enabled !== null || gate.linkedIssue !== null || gate.duplicates !== null || gate.readinessMode !== null || gate.readinessMinScore !== null; + return gate; +} + +/** + * Serialize a gate config back into the parse-compatible `gate:` shape so a cached manifest snapshot + * round-trips through {@link parseGateConfig} unchanged. Returns null when nothing is configured. + */ +export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { + if (!gate.present) return null; + const out: Record = {}; + if (gate.enabled !== null) out.enabled = gate.enabled; + if (gate.linkedIssue !== null) out.linkedIssue = gate.linkedIssue; + if (gate.duplicates !== null) out.duplicates = gate.duplicates; + if (gate.readinessMode !== null || gate.readinessMinScore !== null) { + const readiness: Record = {}; + if (gate.readinessMode !== null) readiness.mode = gate.readinessMode; + if (gate.readinessMinScore !== null) readiness.minScore = gate.readinessMinScore; + out.readiness = readiness; + } + return out; +} + +function normalizeOptionalEnum(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null { + if (value === undefined || value === null) return null; + if (typeof value === "string" && (allowed as readonly string[]).includes(value)) return value as T; + warnings.push(`Manifest settings field "${field}" must be one of ${allowed.join(", ")}; ignoring "${String(value)}".`); + return null; +} + +function normalizeOptionalString(value: JsonValue | undefined, field: string, warnings: string[]): string | null { + if (value === undefined || value === null) return null; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + warnings.push(`Manifest settings field "${field}" must be a non-empty string; ignoring it.`); + return null; +} + +/** + * Parse the optional `settings:` mapping — a partial repository-settings override. Only recognized + * fields are kept; unknown/invalid values are dropped with a warning and never throw. + */ +function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]): FocusManifestSettings { + if (value === undefined || value === null) return {}; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`Manifest field "settings" must be a mapping; ignoring it.`); + return {}; + } + const r = value as Record; + const out: FocusManifestSettings = {}; + const commentMode = normalizeOptionalEnum(r.commentMode, "settings.commentMode", ["off", "detected_contributors_only", "all_prs"] as const, warnings); + if (commentMode !== null) out.commentMode = commentMode; + const publicAudienceMode = normalizeOptionalEnum(r.publicAudienceMode, "settings.publicAudienceMode", ["oss_maintainer", "gittensor_only"] as const, warnings); + if (publicAudienceMode !== null) out.publicAudienceMode = publicAudienceMode; + const publicSignalLevel = normalizeOptionalEnum(r.publicSignalLevel, "settings.publicSignalLevel", ["minimal", "standard"] as const, warnings); + if (publicSignalLevel !== null) out.publicSignalLevel = publicSignalLevel; + const checkRunMode = normalizeOptionalEnum(r.checkRunMode, "settings.checkRunMode", ["off", "enabled"] as const, warnings); + if (checkRunMode !== null) out.checkRunMode = checkRunMode; + const checkRunDetailLevel = normalizeOptionalEnum(r.checkRunDetailLevel, "settings.checkRunDetailLevel", ["minimal", "standard", "deep"] as const, warnings); + if (checkRunDetailLevel !== null) out.checkRunDetailLevel = checkRunDetailLevel; + const gateCheckMode = normalizeOptionalEnum(r.gateCheckMode, "settings.gateCheckMode", ["off", "enabled"] as const, warnings); + if (gateCheckMode !== null) out.gateCheckMode = gateCheckMode; + const linkedIssueGateMode = normalizeOptionalGateMode(r.linkedIssueGateMode, "settings.linkedIssueGateMode", warnings); + if (linkedIssueGateMode !== null) out.linkedIssueGateMode = linkedIssueGateMode; + const duplicatePrGateMode = normalizeOptionalGateMode(r.duplicatePrGateMode, "settings.duplicatePrGateMode", warnings); + if (duplicatePrGateMode !== null) out.duplicatePrGateMode = duplicatePrGateMode; + const qualityGateMode = normalizeOptionalGateMode(r.qualityGateMode, "settings.qualityGateMode", warnings); + if (qualityGateMode !== null) out.qualityGateMode = qualityGateMode; + const qualityGateMinScore = normalizeOptionalScore(r.qualityGateMinScore, "settings.qualityGateMinScore", warnings); + if (qualityGateMinScore !== null) out.qualityGateMinScore = qualityGateMinScore; + const gittensorLabel = normalizeOptionalString(r.gittensorLabel, "settings.gittensorLabel", warnings); + if (gittensorLabel !== null) out.gittensorLabel = gittensorLabel; + 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 ["autoLabelEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled"] as const) { + const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); + if (flag !== null) out[key] = flag; + } + return out; +} + +/** Serialize the settings override for the cache round-trip; returns null when nothing is set. */ +export function settingsOverrideToJson(settings: FocusManifestSettings): JsonValue { + if (Object.keys(settings).length === 0) return null; + return { ...settings } as Record; +} + +/** A bounded, PUBLIC-SAFE maintainer string (footer/note). Trimmed, length-capped, and rejected with a + * warning if it contains any forbidden public term — it is then dropped, never published. */ +function parsePublicSafeText(value: JsonValue | undefined, field: string, warnings: string[]): string | null { + const text = normalizeOptionalString(value, field, warnings); + if (text === null) return null; + const bounded = text.length > MAX_ITEM_LENGTH ? text.slice(0, MAX_ITEM_LENGTH) : text; + if (!isFocusManifestPublicSafe(bounded)) { + warnings.push(`Manifest "${field}" contains content that is not public-safe; ignoring it.`); + return null; + } + return bounded; +} + +/** + * Parse the optional `review:` block — maintainer overrides for the public review-panel content. Never + * throws; invalid/unsafe values are dropped with warnings. + */ +function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestReviewConfig { + const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {} }; + if (value === undefined || value === null) return empty; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`Manifest field "review" must be a mapping; ignoring it.`); + return empty; + } + const r = value as Record; + const footerRecord = r.footer !== null && typeof r.footer === "object" && !Array.isArray(r.footer) ? (r.footer as Record) : undefined; + if (r.footer !== undefined && r.footer !== null && footerRecord === undefined) warnings.push(`Manifest "review.footer" must be a mapping; ignoring it.`); + const fieldsRecord = r.fields !== null && typeof r.fields === "object" && !Array.isArray(r.fields) ? (r.fields as Record) : undefined; + if (r.fields !== undefined && r.fields !== null && fieldsRecord === undefined) warnings.push(`Manifest "review.fields" must be a mapping; ignoring it.`); + const fields: Partial> = {}; + if (fieldsRecord) { + for (const key of REVIEW_FIELD_KEYS) { + const flag = normalizeOptionalBoolean(fieldsRecord[key], `review.fields.${key}`, warnings); + if (flag !== null) fields[key] = flag; + } + } + const footerText = footerRecord ? parsePublicSafeText(footerRecord.text, "review.footer.text", warnings) : null; + const note = parsePublicSafeText(r.note, "review.note", warnings); + return { present: footerText !== null || note !== null || Object.keys(fields).length > 0, footerText, note, fields }; +} + +/** Serialize the review config for the cache round-trip; returns null when nothing is set. */ +export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue { + if (!review.present) return null; + const out: Record = {}; + if (review.footerText !== null) out.footer = { text: review.footerText }; + if (review.note !== null) out.note = review.note; + if (Object.keys(review.fields).length > 0) out.fields = { ...review.fields } as Record; + return out; +} + +/** + * Resolve the EFFECTIVE repository settings a webhook should act on: `.gittensory.yml` > DB settings > + * safe defaults. The generic `settings:` override applies first; the friendly `gate:` alias then wins + * for its fields. This single resolver makes the whole gittensory configuration — gate on/off, blocker + * modes, comments, labels, surface, audience — controllable from the repo's `.gittensory.yml`. + */ +export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifest: FocusManifest): RepositorySettings { + const effective: RepositorySettings = { ...dbSettings, ...manifest.settings }; + const gate = manifest.gate; + if (gate.enabled !== null) effective.gateCheckMode = gate.enabled ? "enabled" : "off"; + if (gate.linkedIssue !== null) effective.linkedIssueGateMode = gate.linkedIssue; + if (gate.duplicates !== null) effective.duplicatePrGateMode = gate.duplicates; + if (gate.readinessMode !== null) effective.qualityGateMode = gate.readinessMode; + if (gate.readinessMinScore !== null) effective.qualityGateMinScore = gate.readinessMinScore; + return effective; +} + /** * Tolerantly normalize an already-parsed manifest object into a {@link FocusManifest}. * Never throws: malformed shapes degrade to safe defaults and accumulate warnings so callers @@ -154,6 +438,9 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): issueDiscoveryPolicy: normalizeEnum(record.issueDiscoveryPolicy, "issueDiscoveryPolicy", ["encouraged", "neutral", "discouraged"] as const, "neutral", warnings), maintainerNotes: normalizeStringList(record.maintainerNotes, "maintainerNotes", warnings), publicNotes: normalizeStringList(record.publicNotes, "publicNotes", warnings).filter(isFocusManifestPublicSafe), + gate: parseGateConfig(record.gate, warnings), + settings: parseSettingsOverride(record.settings, warnings), + review: parseReviewConfig(record.review, warnings), warnings, }; if ( @@ -164,7 +451,10 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): manifest.maintainerNotes.length === 0 && manifest.publicNotes.length === 0 && manifest.linkedIssuePolicy === "optional" && - manifest.issueDiscoveryPolicy === "neutral" + manifest.issueDiscoveryPolicy === "neutral" && + !manifest.gate.present && + Object.keys(manifest.settings).length === 0 && + !manifest.review.present ) { warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals."); manifest.present = false; diff --git a/test/unit/focus-manifest-loader.test.ts b/test/unit/focus-manifest-loader.test.ts index 0a1140934d..475635b70b 100644 --- a/test/unit/focus-manifest-loader.test.ts +++ b/test/unit/focus-manifest-loader.test.ts @@ -209,13 +209,23 @@ describe("focus-manifest loader", () => { expect(manifest.wantedPaths).toContain("apps/gittensory-ui/"); }); - it("does not persist an empty manifest from a failed fetch", async () => { + it("negative-caches an absent manifest so the gate path does not re-fetch every webhook", async () => { const env = createTestEnv(); await loadRepoFocusManifest(env, "owner/empty", { fetcher: async () => null }); const { listSignalSnapshots } = await import("../../src/db/repositories"); const { REPO_FOCUS_MANIFEST_SIGNAL } = await import("../../src/signals/focus-manifest-loader"); const snapshots = await listSignalSnapshots(env, REPO_FOCUS_MANIFEST_SIGNAL, "owner/empty"); - expect(snapshots).toHaveLength(0); + expect(snapshots).toHaveLength(1); + // A second load returns the cached absent manifest without invoking the fetcher again. + let fetches = 0; + const cached = await loadRepoFocusManifest(env, "owner/empty", { + fetcher: async () => { + fetches += 1; + return null; + }, + }); + expect(fetches).toBe(0); + expect(cached.present).toBe(false); }); it("treats a cached snapshot with a missing or unparseable timestamp as stale", async () => { diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 3e4761f3a2..de34a61e6d 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -3,12 +3,17 @@ import { buildFocusManifestGuidance, compileFocusManifestPolicy, deriveContributionLanes, + gateConfigToJson, isFocusManifestPublicSafe, matchesManifestPath, parseFocusManifest, parseFocusManifestContent, + resolveEffectiveSettings, + reviewConfigToJson, + settingsOverrideToJson, type FocusManifest, } from "../../src/signals/focus-manifest"; +import type { RepositorySettings } from "../../src/types"; const FULL_MANIFEST = { source: "repo_file", @@ -395,6 +400,9 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], + gate: { present: false, enabled: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null }, + settings: {}, + review: { present: false, footerText: null, note: null, fields: {} }, warnings: [], }); expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused."); @@ -675,3 +683,195 @@ describe("public-safe invariant", () => { } }); }); + +describe("parseFocusManifest gate config", () => { + it("parses a full gate section including the readiness block", () => { + const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "block", minScore: 70 } } }); + expect(m.present).toBe(true); + expect(m.gate).toEqual({ present: true, enabled: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70 }); + }); + + it("parses gate.enabled (on/off) and ignores non-boolean values with a warning", () => { + expect(parseFocusManifest({ gate: { enabled: true } }).gate.enabled).toBe(true); + expect(parseFocusManifest({ gate: { enabled: false } }).gate.enabled).toBe(false); + expect(parseFocusManifest({ gate: { enabled: true } }).gate.present).toBe(true); + const bad = parseFocusManifest({ gate: { enabled: "yes" } }); + expect(bad.gate.enabled).toBeNull(); + expect(bad.warnings.some((w) => /gate\.enabled/.test(w))).toBe(true); + }); + + it("treats a manifest with ONLY a gate section as present", () => { + const m = parseFocusManifest({ gate: { duplicates: "block" } }); + expect(m.present).toBe(true); + expect(m.gate.present).toBe(true); + expect(m.gate.duplicates).toBe("block"); + }); + + it("leaves unset gate fields null so the resolver falls back to DB settings", () => { + const m = parseFocusManifest({ gate: { linkedIssue: "advisory" } }); + expect(m.gate.linkedIssue).toBe("advisory"); + expect(m.gate.duplicates).toBeNull(); + expect(m.gate.readinessMode).toBeNull(); + expect(m.gate.readinessMinScore).toBeNull(); + }); + + it("ignores invalid gate values with a warning rather than throwing", () => { + const m = parseFocusManifest({ gate: { linkedIssue: "sometimes", duplicates: 5, readiness: { mode: "nope", minScore: "high" } } }); + expect(m.gate.linkedIssue).toBeNull(); + expect(m.gate.duplicates).toBeNull(); + expect(m.gate.readinessMode).toBeNull(); + expect(m.gate.readinessMinScore).toBeNull(); + expect(m.gate.present).toBe(false); + expect(m.warnings.some((w) => /gate\.linkedIssue/.test(w))).toBe(true); + expect(m.warnings.some((w) => /gate\.readiness\.mode/.test(w))).toBe(true); + }); + + it("clamps and rounds the readiness minScore to 0-100", () => { + expect(parseFocusManifest({ gate: { readiness: { minScore: 250 } } }).gate.readinessMinScore).toBe(100); + expect(parseFocusManifest({ gate: { readiness: { minScore: -10 } } }).gate.readinessMinScore).toBe(0); + expect(parseFocusManifest({ gate: { readiness: { minScore: 59.6 } } }).gate.readinessMinScore).toBe(60); + }); + + it("ignores a non-mapping gate or readiness block with a warning", () => { + const m1 = parseFocusManifest({ gate: ["nope"] }); + expect(m1.gate.present).toBe(false); + expect(m1.warnings.some((w) => /"gate" must be a mapping/.test(w))).toBe(true); + const m2 = parseFocusManifest({ gate: { readiness: "nope" } }); + expect(m2.gate.present).toBe(false); + expect(m2.warnings.some((w) => /"gate\.readiness" must be a mapping/.test(w))).toBe(true); + }); + + it("round-trips through gateConfigToJson + parse (the cache path) and serializes empty as null", () => { + const original = parseFocusManifest({ gate: { enabled: false, linkedIssue: "block", readiness: { mode: "advisory", minScore: 42 } } }); + const reparsed = parseFocusManifest({ gate: gateConfigToJson(original.gate) }); + expect(reparsed.gate).toEqual(original.gate); + expect(gateConfigToJson(parseFocusManifest({}).gate)).toBeNull(); + }); + + it("parses the gate section from YAML content", () => { + const m = parseFocusManifestContent("gate:\n duplicates: block\n readiness:\n mode: block\n minScore: 80\n", "repo_file"); + expect(m.gate.duplicates).toBe("block"); + expect(m.gate.readinessMode).toBe("block"); + expect(m.gate.readinessMinScore).toBe(80); + }); +}); + +describe("parseFocusManifest settings override + resolveEffectiveSettings", () => { + it("parses a comprehensive settings: block", () => { + const m = parseFocusManifest({ + settings: { + commentMode: "all_prs", + publicAudienceMode: "gittensor_only", + publicSignalLevel: "minimal", + checkRunMode: "enabled", + checkRunDetailLevel: "deep", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + duplicatePrGateMode: "off", + qualityGateMode: "advisory", + qualityGateMinScore: 65, + autoLabelEnabled: false, + gittensorLabel: "gittensor", + createMissingLabel: true, + publicSurface: "comment_only", + includeMaintainerAuthors: true, + requireLinkedIssue: true, + backfillEnabled: false, + privateTrustEnabled: true, + }, + }); + expect(m.present).toBe(true); + expect(m.settings).toEqual({ + commentMode: "all_prs", + publicAudienceMode: "gittensor_only", + publicSignalLevel: "minimal", + checkRunMode: "enabled", + checkRunDetailLevel: "deep", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + duplicatePrGateMode: "off", + qualityGateMode: "advisory", + qualityGateMinScore: 65, + autoLabelEnabled: false, + gittensorLabel: "gittensor", + createMissingLabel: true, + publicSurface: "comment_only", + includeMaintainerAuthors: true, + requireLinkedIssue: true, + backfillEnabled: false, + privateTrustEnabled: true, + }); + }); + + it("drops invalid settings values with warnings and keeps the valid ones", () => { + const m = parseFocusManifest({ + settings: { commentMode: "loud", qualityGateMinScore: "high", autoLabelEnabled: "yes", gittensorLabel: " ", publicSurface: "comment_only" }, + }); + expect(m.settings).toEqual({ publicSurface: "comment_only" }); + expect(m.warnings.some((w) => /settings\.commentMode/.test(w))).toBe(true); + expect(m.warnings.some((w) => /settings\.qualityGateMinScore/.test(w))).toBe(true); + expect(m.warnings.some((w) => /settings\.autoLabelEnabled/.test(w))).toBe(true); + expect(m.warnings.some((w) => /settings\.gittensorLabel/.test(w))).toBe(true); + }); + + it("ignores a non-mapping settings block and treats a settings-only manifest as present", () => { + expect(parseFocusManifest({ settings: ["nope"] }).warnings.some((w) => /"settings" must be a mapping/.test(w))).toBe(true); + expect(parseFocusManifest({ settings: { commentMode: "off" } }).present).toBe(true); + }); + + it("round-trips settings through settingsOverrideToJson and serializes empty as null", () => { + const original = parseFocusManifest({ settings: { commentMode: "all_prs", qualityGateMinScore: 40 } }); + const reparsed = parseFocusManifest({ settings: settingsOverrideToJson(original.settings) }); + expect(reparsed.settings).toEqual(original.settings); + expect(settingsOverrideToJson(parseFocusManifest({}).settings)).toBeNull(); + }); + + 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( + db, + parseFocusManifest({ settings: { commentMode: "all_prs", linkedIssueGateMode: "advisory", autoLabelEnabled: false }, gate: { enabled: true, linkedIssue: "block" } }), + ); + expect(eff.commentMode).toBe("all_prs"); // settings: override + expect(eff.autoLabelEnabled).toBe(false); // settings: override (boolean) + expect(eff.gateCheckMode).toBe("enabled"); // gate.enabled + expect(eff.linkedIssueGateMode).toBe("block"); // gate: wins over settings: + }); +}); + +describe("parseFocusManifest review config", () => { + it("parses footer text, field toggles, and a note", () => { + const m = parseFocusManifest({ review: { footer: { text: "Reviewed by the Acme bot." }, fields: { relatedWork: false, gateResult: true }, note: "Run npm test before pushing." } }); + expect(m.present).toBe(true); + expect(m.review.footerText).toBe("Reviewed by the Acme bot."); + expect(m.review.note).toBe("Run npm test before pushing."); + expect(m.review.fields).toEqual({ relatedWork: false, gateResult: true }); + }); + + it("drops footer/note content that is not public-safe, with a warning", () => { + const m = parseFocusManifest({ review: { footer: { text: "Estimate your reward payout here" }, note: "paste your wallet hotkey" } }); + expect(m.review.footerText).toBeNull(); + expect(m.review.note).toBeNull(); + expect(m.warnings.some((w) => /review\.footer\.text.*public-safe/.test(w))).toBe(true); + expect(m.warnings.some((w) => /review\.note.*public-safe/.test(w))).toBe(true); + }); + + it("ignores invalid field toggles and non-mapping footer/fields with warnings", () => { + const m = parseFocusManifest({ review: { footer: ["nope"], fields: "nope" } }); + expect(m.review.present).toBe(false); + expect(m.warnings.some((w) => /"review\.footer" must be a mapping/.test(w))).toBe(true); + expect(m.warnings.some((w) => /"review\.fields" must be a mapping/.test(w))).toBe(true); + const m2 = parseFocusManifest({ review: { fields: { gateResult: "yes" } } }); + expect(m2.review.fields).toEqual({}); + expect(m2.warnings.some((w) => /review\.fields\.gateResult/.test(w))).toBe(true); + }); + + it("ignores a non-mapping review block, treats a review-only manifest as present, and round-trips", () => { + expect(parseFocusManifest({ review: ["nope"] }).warnings.some((w) => /"review" must be a mapping/.test(w))).toBe(true); + const original = parseFocusManifest({ review: { footer: { text: "Custom." }, fields: { openPrQueue: false }, note: "Note." } }); + expect(original.present).toBe(true); + const reparsed = parseFocusManifest({ review: reviewConfigToJson(original.review) }); + expect(reparsed.review).toEqual(original.review); + expect(reviewConfigToJson(parseFocusManifest({}).review)).toBeNull(); + }); +}); diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts new file mode 100644 index 0000000000..2f6aae0777 --- /dev/null +++ b/test/unit/gate-check-policy.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { gateCheckPolicy } from "../../src/queue/processors"; +import { evaluateGateCheck } from "../../src/rules/advisory"; +import { parseFocusManifest, resolveEffectiveSettings } from "../../src/signals/focus-manifest"; +import type { Advisory, RepositorySettings } from "../../src/types"; + +function settings(over: Partial = {}): RepositorySettings { + return { + commentMode: "detected_contributors_only", + gateCheckMode: "enabled", + linkedIssueGateMode: "advisory", + duplicatePrGateMode: "block", + qualityGateMode: "advisory", + qualityGateMinScore: null, + ...over, + } as unknown as RepositorySettings; +} + +function missingIssueAdvisory(): Advisory { + return { + id: "advisory-policy", + targetType: "pull_request", + targetKey: "owner/repo#7", + repoFullName: "owner/repo", + pullNumber: 7, + headSha: "sha7", + conclusion: "neutral", + severity: "warning", + title: "Gittensory advisory available", + summary: "1 advisory finding generated.", + findings: [{ code: "missing_linked_issue", title: "No linked issue detected", severity: "warning", detail: "No closing reference.", action: "Link the issue." }], + generatedAt: "2026-06-13T00:00:00.000Z", + }; +} + +describe(".gittensory.yml settings override (resolveEffectiveSettings)", () => { + it("returns the DB settings unchanged when the manifest has no overrides", () => { + const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "block" }), parseFocusManifest(null)); + expect(eff.linkedIssueGateMode).toBe("block"); + expect(eff.duplicatePrGateMode).toBe("block"); + expect(eff.gateCheckMode).toBe("enabled"); + }); + + it("overlays the friendly gate: alias over DB settings (incl. gate.enabled -> gateCheckMode)", () => { + const eff = resolveEffectiveSettings( + settings({ gateCheckMode: "enabled", linkedIssueGateMode: "advisory", duplicatePrGateMode: "block", qualityGateMode: "off", qualityGateMinScore: 10 }), + parseFocusManifest({ gate: { enabled: false, linkedIssue: "block", duplicates: "off", readiness: { mode: "block", minScore: 70 } } }), + ); + expect(eff.gateCheckMode).toBe("off"); // gate.enabled: false disables from config + expect(eff.linkedIssueGateMode).toBe("block"); + expect(eff.duplicatePrGateMode).toBe("off"); + expect(eff.qualityGateMode).toBe("block"); + expect(eff.qualityGateMinScore).toBe(70); + }); + + it("overlays the generic settings: block over DB, and gate: wins for gate fields", () => { + const eff = resolveEffectiveSettings( + settings({ commentMode: "off", publicSurface: "off", gateCheckMode: "off", linkedIssueGateMode: "off" }), + parseFocusManifest({ settings: { commentMode: "all_prs", publicSurface: "comment_only", gateCheckMode: "enabled", linkedIssueGateMode: "advisory" }, gate: { linkedIssue: "block" } }), + ); + expect(eff.commentMode).toBe("all_prs"); // settings: override + expect(eff.publicSurface).toBe("comment_only"); // settings: override + expect(eff.gateCheckMode).toBe("enabled"); // settings: override (config enables the gate) + expect(eff.linkedIssueGateMode).toBe("block"); // gate: wins over settings: + }); + + it("end-to-end: a manifest linkedIssue:block blocks a confirmed author's no-issue PR even when DB is advisory", () => { + const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "advisory" }), parseFocusManifest({ gate: { linkedIssue: "block" } })); + const blocked = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, true)); + expect(blocked.conclusion).toBe("failure"); + expect(blocked.blockers.map((finding) => finding.code)).toEqual(["missing_linked_issue"]); + }); + + it("end-to-end: a manifest linkedIssue:advisory un-blocks even when DB is block (config-as-code relief)", () => { + const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "block" }), parseFocusManifest({ gate: { linkedIssue: "advisory" } })); + expect(evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, true)).conclusion).toBe("success"); + }); + + it("still only blocks confirmed contributors regardless of the config", () => { + const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "advisory" }), parseFocusManifest({ gate: { linkedIssue: "block" } })); + const nonConfirmed = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, false)); + expect(nonConfirmed.conclusion).toBe("neutral"); + expect(nonConfirmed.blockers).toEqual([]); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 3cf6700cf2..37abcaa861 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -29,6 +29,7 @@ import { upsertRepositoryFromGitHub, } from "../../src/db/repositories"; import { processJob } from "../../src/queue/processors"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; import { normalizeRegistryPayload } from "../../src/registry/normalize"; import { persistRegistrySnapshot } from "../../src/registry/sync"; import { createTestEnv } from "../helpers/d1"; @@ -736,6 +737,8 @@ describe("queue processors", () => { return new Response("not found", { status: 404 }); }); + // .gittensory.yml authoritatively sets the linked-issue blocker to "block" (config-as-code). + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); await processJob(env, { type: "github-webhook", deliveryId: "gate-only", @@ -801,6 +804,7 @@ describe("queue processors", () => { return new Response("not found", { status: 404 }); }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); await processJob(env, { type: "github-webhook", deliveryId: "gate-bot-public-skip", @@ -871,6 +875,7 @@ describe("queue processors", () => { return new Response("not found", { status: 404 }); }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); await processJob(env, { type: "github-webhook", deliveryId: "gate-unconfirmed-miner-public-skip", @@ -941,6 +946,7 @@ describe("queue processors", () => { return new Response("not found", { status: 404 }); }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); await processJob(env, { type: "github-webhook", deliveryId: "gate-confirmed-block", @@ -962,6 +968,52 @@ describe("queue processors", () => { expect(gatePatchBody.output?.title).toBe("Gittensory Gate: No linked issue detected"); }); + it("disables the gate from .gittensory.yml (gate.enabled: false) even when repo settings enable it", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + 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, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + requireLinkedIssue: true, + }); + // Config turns the gate OFF even though repo settings have gateCheckMode: enabled. + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { enabled: false } }); + const calls = { gateChecks: 0 }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/check-runs")) { + calls.gateChecks += 1; + return Response.json({ id: 999 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-yml-disabled", + 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: 70, title: "No issue", state: "open", user: { login: "contributor" }, head: { sha: "ymldisabled123" }, labels: [], body: "No issue." }, + }, + }); + + // gate.enabled: false in .gittensory.yml disables the gate entirely — no Gate check is posted. + expect(calls.gateChecks).toBe(0); + }); + it("audits opt-in gate check permission failures without blocking webhook processing", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await persistRegistrySnapshot( @@ -1435,6 +1487,7 @@ describe("queue processors", () => { issue: { number: 46, title: "Panel skip", state: "open", user: { login: "contributor" }, pull_request: {} }, }; + await upsertRepoFocusManifest(env, "JSONbored/gittensory", {}); await processJob(env, { type: "github-webhook", deliveryId: "panel-rerun-created-ignore", @@ -1564,6 +1617,7 @@ describe("queue processors", () => { return new Response("unexpected public call", { status: 500 }); }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", {}); await processJob(env, { type: "github-webhook", deliveryId: "pr-labeled-noisy", @@ -2041,6 +2095,7 @@ describe("queue processors", () => { return new Response("unexpected fetch", { status: 500 }); }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", {}); await processJob(env, { type: "github-webhook", deliveryId: "surface-off-skip", @@ -2559,6 +2614,7 @@ describe("queue processors", () => { return new Response("gittensor unavailable", { status: 503 }); }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", {}); await expect(processJob(env, { type: "github-webhook", deliveryId: "miner-unavailable", eventName: "pull_request", payload })).resolves.toBeUndefined(); await expect( processJob(env, { diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 6342c0cadd..a142d486c9 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -788,6 +788,24 @@ describe("signal coverage edge cases", () => { expect(passingGateComment).toContain("> | Gate result | ✅ Passing | No configured blocker found. | No action. |"); expect(passingGateComment).toContain("Public GitHub metadata was checked"); + // .gittensory.yml review overrides: custom footer lead, an intro note, and a hidden row. + const customizedComment = buildPublicPrIntelligenceComment({ + repo: directRepo, + pr: { ...currentPr, linkedIssues: [99], body: "Fixes #99" }, + profile, + detection, + queueHealth: buildQueueHealth(directRepo, [], [currentPr], buildCollisionReport(directRepo.fullName, [], [currentPr])), + collisions: buildCollisionReport(directRepo.fullName, [], [currentPr]), + preflight: buildPreflightResult({ repoFullName: directRepo.fullName, title: "Fix isolated issue", body: "Fixes #99", linkedIssues: [99] }, directRepo, [], [currentPr]), + settings: gateSettings, + review: { present: true, footerText: "Reviewed by the Acme maintainer bot.", note: "Run npm test before pushing.", fields: { relatedWork: false } }, + }); + expect(customizedComment).toContain("Reviewed by the Acme maintainer bot."); // custom footer lead + expect(customizedComment).toContain("register to start earning"); // mandatory attribution/earn link kept + expect(customizedComment).toContain("Run npm test before pushing."); // intro note + expect(customizedComment).not.toContain("| Related work |"); // hidden row + expect(customizedComment).toContain("| Gate result |"); // non-hidden rows still rendered + const advisoryOnlyComment = buildPublicPrIntelligenceComment({ repo: directRepo, pr: { ...currentPr, linkedIssues: [99], body: "Fixes #99" },