From e5c667cb74a90b9f5868879a444d52aa2a3cd690 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:45:16 -0700 Subject: [PATCH 1/4] feat(github): authoritative .gittensory.yml gate config (config-as-code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B (config-as-code foundation): let maintainers declare the Gittensory Gate's blocker policy in `.gittensory.yml` under `gate:`, layered over dashboard settings. - Extend the existing repo focus-manifest loader (fetch + 6h cache, graceful fallback) with a `gate:` section: `linkedIssue`, `duplicates`, and `readiness: { mode, minScore }` (each off|advisory|block). Unset fields stay null so the resolver falls back per-field. - Precedence: `.gittensory.yml` > DB RepositorySettings > safe defaults, resolved in `gateCheckPolicy`. The manifest is loaded only on the enabled-gate path (cached), so gate-off repos pay no fetch cost. - Only selects which deterministic blockers are active; turning the gate on/off stays a repository setting (`gateCheckMode`), and the Phase A confirmed-contributor invariant is preserved — only confirmed Gittensor contributors are ever hard-blocked. - Dogfood: the repo's own `.gittensory.yml` (and bundled mirror) declare a `gate:` block; CONTRIBUTING documents the schema + precedence. Tests: parseGateConfig (valid/invalid/partial/readiness/round-trip/YAML), gateCheckPolicy precedence (manifest > DB, per-field fallback), and end-to-end evaluateGateCheck precedence incl. the confirmed-contributor invariant. Coverage holds the 97% gate. The deterministic slop-risk blocker remains tracked in #635 (contributor PR); this PR is the config-as-code layer it can plug into. --- .gittensory.yml | 10 ++ CONTRIBUTING.md | 12 +++ src/config/gittensory-repo-focus-manifest.ts | 10 ++ src/queue/processors.ts | 29 ++++-- src/signals/focus-manifest-loader.ts | 3 +- src/signals/focus-manifest.ts | 96 +++++++++++++++++++- test/unit/focus-manifest.test.ts | 65 +++++++++++++ test/unit/gate-check-policy.test.ts | 85 +++++++++++++++++ 8 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 test/unit/gate-check-policy.test.ts diff --git a/.gittensory.yml b/.gittensory.yml index fc68bd55b5..0faef3e7f8 100644 --- a/.gittensory.yml +++ b/.gittensory.yml @@ -32,6 +32,16 @@ testExpectations: issueDiscoveryPolicy: discouraged +# Authoritative gate blocker policy, config-as-code (layered OVER dashboard repository settings: +# .gittensory.yml > DB settings > safe defaults). This only selects which deterministic blockers are +# active — ONLY confirmed Gittensor contributors are ever hard-blocked (see PR #644). Turning the gate +# itself on/off remains a repository setting (gateCheckMode). +gate: + 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..f60770d1a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -189,6 +189,18 @@ 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`. + +Gate config as code (`.gittensory.yml`): + +- Maintainers can declare the gate's blocker policy in `.gittensory.yml` under `gate:` — + `linkedIssue`, `duplicates`, and `readiness: { mode, minScore }`, each `off | advisory | block`. +- Precedence is `.gittensory.yml` > dashboard repository settings > safe defaults; an unset field + falls back to the next layer. The committed root `.gittensory.yml` is the worked example. +- This only selects which deterministic blockers are active. Turning the gate on/off remains a + repository setting (`gateCheckMode`), and only confirmed contributors are ever hard-blocked. ## Commit And PR Titles diff --git a/src/config/gittensory-repo-focus-manifest.ts b/src/config/gittensory-repo-focus-manifest.ts index 1c548cf4c2..f3ce762b00 100644 --- a/src/config/gittensory-repo-focus-manifest.ts +++ b/src/config/gittensory-repo-focus-manifest.ts @@ -36,6 +36,16 @@ testExpectations: issueDiscoveryPolicy: discouraged +# Authoritative gate blocker policy, config-as-code (layered OVER dashboard repository settings: +# .gittensory.yml > DB settings > safe defaults). This only selects which deterministic blockers are +# active — ONLY confirmed Gittensor contributors are ever hard-blocked (see PR #644). Turning the gate +# itself on/off remains a repository setting (gateCheckMode). +gate: + 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/queue/processors.ts b/src/queue/processors.ts index a0550b31a5..c5f98bfac5 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 type { FocusManifestGateConfig } 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"; @@ -780,12 +782,20 @@ 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, + manifestGate?: FocusManifestGateConfig | undefined, +) { + // `.gittensory.yml` `gate:` config wins over DB settings where the maintainer set it (manifest > DB > + // defaults). It only selects which deterministic blockers are active; confirmedContributor still + // governs WHO can be blocked, downstream in evaluateGateCheck. return { - linkedIssueGateMode: settings.linkedIssueGateMode, - duplicatePrGateMode: settings.duplicatePrGateMode, - qualityGateMode: settings.qualityGateMode, - qualityGateMinScore: settings.qualityGateMinScore ?? null, + linkedIssueGateMode: manifestGate?.linkedIssue ?? settings.linkedIssueGateMode, + duplicatePrGateMode: manifestGate?.duplicates ?? settings.duplicatePrGateMode, + qualityGateMode: manifestGate?.readinessMode ?? settings.qualityGateMode, + qualityGateMinScore: manifestGate?.readinessMinScore ?? settings.qualityGateMinScore ?? null, readinessScore: readinessScore ?? null, confirmedContributor, }; @@ -959,15 +969,20 @@ async function maybePublishPrPublicSurface( // Only CONFIRMED gittensor contributors can be hard-blocked; everyone else (or an unavailable // detection) gets a neutral, non-blocking gate. `official` may be null if no public output ran. const confirmedContributor = official?.status === "confirmed"; + // Load the maintainer's `.gittensory.yml` gate config ONLY when the gate is enabled (cached; gate-off + // repos never pay the fetch). It authoritatively refines the blocker policy over DB settings. + const manifestGate = gateEnabled ? (await loadRepoFocusManifest(env, repoFullName)).gate : undefined; const gateEvaluation = - settings.gateCheckMode === "enabled" ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor)) : undefined; + settings.gateCheckMode === "enabled" + ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor, manifestGate)) + : undefined; if (gateEnabled) { const gateCheckResult = await createOrUpdateGateCheckRun( env, installationId, repoFullName, advisory, - gateCheckPolicy(settings, readiness.total, confirmedContributor), + gateCheckPolicy(settings, readiness.total, confirmedContributor, manifestGate), { checkRunId: pendingGateCheckRunId, }, diff --git a/src/signals/focus-manifest-loader.ts b/src/signals/focus-manifest-loader.ts index 2d2d0dacac..2e0e739161 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, 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"; @@ -179,6 +179,7 @@ function manifestToJson(manifest: FocusManifest): Record { issueDiscoveryPolicy: manifest.issueDiscoveryPolicy, maintainerNotes: manifest.maintainerNotes, publicNotes: manifest.publicNotes, + gate: gateConfigToJson(manifest.gate), }; } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index f5d44e9d0c..75642a5f60 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -1,10 +1,27 @@ import { parse as parseYaml } from "yaml"; -import type { JsonValue } from "../types"; +import type { GateRuleMode, JsonValue } 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; + linkedIssue: GateRuleMode | null; + duplicates: GateRuleMode | null; + readinessMode: GateRuleMode | null; + readinessMinScore: number | null; +}; + /** * 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 +39,7 @@ export type FocusManifest = { issueDiscoveryPolicy: FocusManifestIssueDiscoveryPolicy; maintainerNotes: string[]; publicNotes: string[]; + gate: FocusManifestGateConfig; warnings: string[]; }; @@ -60,6 +78,14 @@ 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, + linkedIssue: null, + duplicates: null, + readinessMode: null, + readinessMinScore: null, +}; + const EMPTY_MANIFEST: FocusManifest = { present: false, source: "none", @@ -71,6 +97,7 @@ const EMPTY_MANIFEST: FocusManifest = { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: [], + gate: { ...EMPTY_GATE_CONFIG }, warnings: [], }; @@ -83,7 +110,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 } }; } function normalizeStringList(value: JsonValue | undefined, field: string, warnings: string[]): string[] { @@ -131,6 +158,67 @@ 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 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, + 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.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.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; +} + /** * 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 +242,7 @@ 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), warnings, }; if ( @@ -164,7 +253,8 @@ 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 ) { warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals."); manifest.present = false; diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 3e4761f3a2..a3080852f0 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -3,6 +3,7 @@ import { buildFocusManifestGuidance, compileFocusManifestPolicy, deriveContributionLanes, + gateConfigToJson, isFocusManifestPublicSafe, matchesManifestPath, parseFocusManifest, @@ -395,6 +396,7 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], + gate: { present: false, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null }, warnings: [], }); expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused."); @@ -675,3 +677,66 @@ 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, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70 }); + }); + + 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: { 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); + }); +}); diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts new file mode 100644 index 0000000000..8eca91ce54 --- /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 type { FocusManifestGateConfig } from "../../src/signals/focus-manifest"; +import type { Advisory, RepositorySettings } from "../../src/types"; + +function settings(over: Partial = {}): RepositorySettings { + return { + linkedIssueGateMode: "advisory", + duplicatePrGateMode: "block", + qualityGateMode: "advisory", + qualityGateMinScore: null, + ...over, + } as unknown as RepositorySettings; +} + +function gate(over: Partial = {}): FocusManifestGateConfig { + return { present: true, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, ...over }; +} + +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("gateCheckPolicy precedence (.gittensory.yml gate config > DB settings)", () => { + it("uses DB settings when no manifest gate config is provided", () => { + const policy = gateCheckPolicy(settings({ linkedIssueGateMode: "block" }), 80, true); + expect(policy.linkedIssueGateMode).toBe("block"); + expect(policy.duplicatePrGateMode).toBe("block"); + expect(policy.qualityGateMode).toBe("advisory"); + expect(policy.readinessScore).toBe(80); + expect(policy.confirmedContributor).toBe(true); + }); + + it("lets the manifest authoritatively override each blocker mode over DB settings", () => { + const policy = gateCheckPolicy( + settings({ linkedIssueGateMode: "advisory", duplicatePrGateMode: "block", qualityGateMode: "off", qualityGateMinScore: 10 }), + 55, + true, + gate({ linkedIssue: "block", duplicates: "off", readinessMode: "block", readinessMinScore: 70 }), + ); + expect(policy.linkedIssueGateMode).toBe("block"); // manifest "block" beats DB "advisory" + expect(policy.duplicatePrGateMode).toBe("off"); // manifest "off" beats DB "block" + expect(policy.qualityGateMode).toBe("block"); // manifest readiness.mode + expect(policy.qualityGateMinScore).toBe(70); // manifest readiness.minScore + }); + + it("falls back to DB per-field when only some manifest fields are set", () => { + const policy = gateCheckPolicy(settings({ linkedIssueGateMode: "advisory", duplicatePrGateMode: "block" }), null, false, gate({ linkedIssue: "block" })); + expect(policy.linkedIssueGateMode).toBe("block"); // overridden by manifest + expect(policy.duplicatePrGateMode).toBe("block"); // falls back to DB + expect(policy.qualityGateMode).toBe("advisory"); // falls back to DB + expect(policy.confirmedContributor).toBe(false); + }); + + it("end-to-end: a manifest linkedIssue:block blocks a confirmed author's no-issue PR even when DB is advisory", () => { + const blocked = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(settings({ linkedIssueGateMode: "advisory" }), null, true, gate({ linkedIssue: "block" }))); + 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 relieved = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(settings({ linkedIssueGateMode: "block" }), null, true, gate({ linkedIssue: "advisory" }))); + expect(relieved.conclusion).toBe("success"); + }); + + it("still only blocks confirmed contributors regardless of the manifest config", () => { + const nonConfirmed = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(settings({ linkedIssueGateMode: "advisory" }), null, false, gate({ linkedIssue: "block" }))); + expect(nonConfirmed.conclusion).toBe("neutral"); + expect(nonConfirmed.blockers).toEqual([]); + }); +}); From 052262bb61a2433b24805529f65f5815713a0863 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:16:21 -0700 Subject: [PATCH 2/4] =?UTF-8?q?feat(github):=20make=20the=20gate=20fully?= =?UTF-8?q?=20config-driven=20=E2=80=94=20gate.enabled=20+=20linkedIssue?= =?UTF-8?q?=20in=20.gittensory.yml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes config-as-code control of the Gittensory Gate (follow-up within this PR): - `gate.enabled: false` disables the gate from `.gittensory.yml`. The manifest is loaded when the gate is enabled in settings (so dormant repos stay network-free); turning the gate on for the first time stays a one-click repository setting (gateCheckMode). - This repo's own `.gittensory.yml` now sets `linkedIssue: advisory` (+ a documented `enabled` example), so missing-linked-issue no longer hard-blocks confirmed contributors here — the durable, version- controlled fix for the repeated "No linked issue detected" blocks. - Precedence unchanged: `.gittensory.yml` > DB settings > safe defaults, all through the single confirmedContributor-gated evaluateGateCheck path. Only confirmed contributors are ever hard-blocked. Tests: gate.enabled parse + an end-to-end disable (config turns the gate off despite gateCheckMode enabled); the 4 self-repo gate tests now drive their blocker via a seeded `.gittensory.yml` manifest (proving config control); gateConfigToJson round-trips enabled. typecheck + 97% coverage hold. --- .gittensory.yml | 9 ++-- CONTRIBUTING.md | 10 ++-- src/config/gittensory-repo-focus-manifest.ts | 9 ++-- src/queue/processors.ts | 18 ++++--- src/signals/focus-manifest.ts | 14 +++++- test/unit/focus-manifest.test.ts | 15 ++++-- test/unit/gate-check-policy.test.ts | 2 +- test/unit/queue.test.ts | 52 ++++++++++++++++++++ 8 files changed, 104 insertions(+), 25 deletions(-) diff --git a/.gittensory.yml b/.gittensory.yml index 0faef3e7f8..87a4992086 100644 --- a/.gittensory.yml +++ b/.gittensory.yml @@ -32,11 +32,12 @@ testExpectations: issueDiscoveryPolicy: discouraged -# Authoritative gate blocker policy, config-as-code (layered OVER dashboard repository settings: -# .gittensory.yml > DB settings > safe defaults). This only selects which deterministic blockers are -# active — ONLY confirmed Gittensor contributors are ever hard-blocked (see PR #644). Turning the gate -# itself on/off remains a repository setting (gateCheckMode). +# 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f60770d1a9..d0a6674de7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -195,12 +195,14 @@ Public GitHub surfaces: Gate config as code (`.gittensory.yml`): -- Maintainers can declare the gate's blocker policy in `.gittensory.yml` under `gate:` — - `linkedIssue`, `duplicates`, and `readiness: { mode, minScore }`, each `off | advisory | block`. +- Maintainers can declare the gate policy in `.gittensory.yml` under `gate:` — `linkedIssue`, + `duplicates`, and `readiness: { mode, minScore }` (each `off | advisory | block`), plus `enabled`. - Precedence is `.gittensory.yml` > dashboard repository settings > safe defaults; an unset field falls back to the next layer. The committed root `.gittensory.yml` is the worked example. -- This only selects which deterministic blockers are active. Turning the gate on/off remains a - repository setting (`gateCheckMode`), and only confirmed contributors are ever hard-blocked. +- `enabled: false` disables the gate from config; turning it on for the first time is a one-click + repository setting (`gateCheckMode`), so dormant repos never pay a per-PR manifest fetch. +- This only selects what the gate does. Only confirmed Gittensor contributors are ever hard-blocked, + regardless of the manifest. ## Commit And PR Titles diff --git a/src/config/gittensory-repo-focus-manifest.ts b/src/config/gittensory-repo-focus-manifest.ts index f3ce762b00..f6beb1699e 100644 --- a/src/config/gittensory-repo-focus-manifest.ts +++ b/src/config/gittensory-repo-focus-manifest.ts @@ -36,11 +36,12 @@ testExpectations: issueDiscoveryPolicy: discouraged -# Authoritative gate blocker policy, config-as-code (layered OVER dashboard repository settings: -# .gittensory.yml > DB settings > safe defaults). This only selects which deterministic blockers are -# active — ONLY confirmed Gittensor contributors are ever hard-blocked (see PR #644). Turning the gate -# itself on/off remains a repository setting (gateCheckMode). +# 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 diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 90856281b1..33182e65fc 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -860,7 +860,13 @@ async function maybePublishPrPublicSurface( webhook: { deliveryId: string; authorType?: string | undefined; action?: string | undefined }, ): Promise { const author = pr.authorLogin ?? null; - const gateEnabled = settings.gateCheckMode === "enabled" && Boolean(advisory.headSha); + // `.gittensory.yml` authoritatively controls the gate (yml > DB settings > defaults). It is loaded only + // when the gate is enabled in settings (so dormant repos stay network-free), then it refines the blocker + // policy and may DISABLE the gate (gate.enabled: false). It only chooses what the gate does — + // confirmedContributor still governs WHO can be blocked, downstream in evaluateGateCheck. + const gateDbEnabled = settings.gateCheckMode === "enabled" && Boolean(advisory.headSha); + const manifestGate = gateDbEnabled ? (await loadRepoFocusManifest(env, repoFullName)).gate : undefined; + const gateEnabled = gateDbEnabled && manifestGate?.enabled !== false; // Cheap, network-free skip checks (also avoids the miner lookup when it would be wasted). const prelim = decidePublicSurface({ settings, @@ -977,13 +983,9 @@ 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"; - // Load the maintainer's `.gittensory.yml` gate config ONLY when the gate is enabled (cached; gate-off - // repos never pay the fetch). It authoritatively refines the blocker policy over DB settings. - const manifestGate = gateEnabled ? (await loadRepoFocusManifest(env, repoFullName)).gate : undefined; - const gateEvaluation = - settings.gateCheckMode === "enabled" - ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor, manifestGate)) - : undefined; + const gateEvaluation = gateEnabled + ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor, manifestGate)) + : undefined; if (gateEnabled) { const gateCheckResult = await createOrUpdateGateCheckRun( env, diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 75642a5f60..c01658b979 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -16,6 +16,7 @@ export type FocusManifestIssueDiscoveryPolicy = "encouraged" | "neutral" | "disc */ export type FocusManifestGateConfig = { present: boolean; + enabled: boolean | null; linkedIssue: GateRuleMode | null; duplicates: GateRuleMode | null; readinessMode: GateRuleMode | null; @@ -80,6 +81,7 @@ export const MAX_FOCUS_MANIFEST_BYTES = 64 * 1024; const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { present: false, + enabled: null, linkedIssue: null, duplicates: null, readinessMode: null, @@ -165,6 +167,13 @@ function normalizeOptionalGateMode(value: JsonValue | undefined, field: string, 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)) { @@ -192,12 +201,14 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu } 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.linkedIssue !== null || gate.duplicates !== null || gate.readinessMode !== null || gate.readinessMinScore !== null; + gate.present = + gate.enabled !== null || gate.linkedIssue !== null || gate.duplicates !== null || gate.readinessMode !== null || gate.readinessMinScore !== null; return gate; } @@ -208,6 +219,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu 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) { diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index a3080852f0..c8d24cee52 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -396,7 +396,7 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], - gate: { present: false, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null }, + gate: { present: false, enabled: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null }, warnings: [], }); expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused."); @@ -682,7 +682,16 @@ 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, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70 }); + 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", () => { @@ -727,7 +736,7 @@ describe("parseFocusManifest gate config", () => { }); it("round-trips through gateConfigToJson + parse (the cache path) and serializes empty as null", () => { - const original = parseFocusManifest({ gate: { linkedIssue: "block", readiness: { mode: "advisory", minScore: 42 } } }); + 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(); diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index 8eca91ce54..dcafaedce3 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -15,7 +15,7 @@ function settings(over: Partial = {}): RepositorySettings { } function gate(over: Partial = {}): FocusManifestGateConfig { - return { present: true, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, ...over }; + return { present: true, enabled: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, ...over }; } function missingIssueAdvisory(): Advisory { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 3cf6700cf2..88ed6f8e13 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( From 84a568ef49b760dcece714db0660429c8a34ecf4 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:43:05 -0700 Subject: [PATCH 3/4] feat(github): make EVERY repository setting controllable from .gittensory.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns `.gittensory.yml` into a full settings-override layer resolved once in resolveRepositorySettings (`.gittensory.yml` > dashboard settings > safe defaults), so the whole app — gate on/off + blocker modes, comments, labels, surface, audience — honours the config file, with no dashboard dependency. - `settings:` block: a partial of the repository settings (commentMode, publicSurface, gateCheckMode, the gate modes, autoLabelEnabled, gittensorLabel, requireLinkedIssue, backfillEnabled, ...), tolerantly parsed — invalid values dropped with warnings, never throws. - `gate:` stays as the friendly gate alias and wins over `settings:` for its fields; `gate.enabled` now turns the gate on/off purely from config (no dashboard step). - The overlay happens at the SINGLE settings-resolution point for webhooks, so the gate and every other consumer read effective settings (removed the per-gate manifestGate threading — simpler and uniform). - The manifest is negative-cached (absent manifests persisted) so loading it on every webhook is a cached DB read after the first call, not a repeated raw-file fetch. - Only confirmed Gittensor contributors are ever hard-blocked — unchanged. Tests: settings: parse (full / invalid+warnings / non-mapping / round-trip), resolveEffectiveSettings precedence (settings: over DB, gate: over settings:, gate.enabled), end-to-end gate config control + the confirmed-contributor invariant, and a negative-cache test. typecheck + 97% coverage hold. --- CONTRIBUTING.md | 23 +++-- src/queue/processors.ts | 56 +++++------ src/signals/focus-manifest-loader.ts | 10 +- src/signals/focus-manifest.ts | 121 +++++++++++++++++++++++- test/unit/focus-manifest-loader.test.ts | 14 ++- test/unit/focus-manifest.test.ts | 87 +++++++++++++++++ test/unit/gate-check-policy.test.ts | 68 ++++++------- test/unit/queue.test.ts | 4 + 8 files changed, 302 insertions(+), 81 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0a6674de7..787d9bbd3d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -193,16 +193,19 @@ Public GitHub surfaces: app/infra state) resolves to a neutral, non-blocking gate. Adding a blocker must keep it confirmed-contributor-gated through `evaluateGateCheck`. -Gate config as code (`.gittensory.yml`): - -- Maintainers can declare the gate policy in `.gittensory.yml` under `gate:` — `linkedIssue`, - `duplicates`, and `readiness: { mode, minScore }` (each `off | advisory | block`), plus `enabled`. -- Precedence is `.gittensory.yml` > dashboard repository settings > safe defaults; an unset field - falls back to the next layer. The committed root `.gittensory.yml` is the worked example. -- `enabled: false` disables the gate from config; turning it on for the first time is a one-click - repository setting (`gateCheckMode`), so dormant repos never pay a per-PR manifest fetch. -- This only selects what the gate does. Only confirmed Gittensor contributors are ever hard-blocked, - regardless of the manifest. +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`). +- 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); it never + changes **who** can be blocked — only confirmed Gittensor contributors are ever hard-blocked. ## Commit And PR Titles diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 33182e65fc..16daeb7194 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -130,7 +130,7 @@ import { } from "../signals/engine"; import { decidePublicSurface } from "../signals/settings-preview"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; -import type { FocusManifestGateConfig } from "../signals/focus-manifest"; +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"; @@ -697,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, { @@ -782,25 +782,30 @@ function shouldProcessPullRequestPublicSurface(action: string | undefined): bool return PR_PUBLIC_SURFACE_ACTIONS.has(action ?? "") || PR_GATE_CLOSED_ACTIONS.has(action ?? ""); } -export function gateCheckPolicy( - settings: RepositorySettings, - readinessScore?: number | null, - confirmedContributor?: boolean, - manifestGate?: FocusManifestGateConfig | undefined, -) { - // `.gittensory.yml` `gate:` config wins over DB settings where the maintainer set it (manifest > DB > - // defaults). It only selects which deterministic blockers are active; confirmedContributor still - // governs WHO can be blocked, downstream in evaluateGateCheck. +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: manifestGate?.linkedIssue ?? settings.linkedIssueGateMode, - duplicatePrGateMode: manifestGate?.duplicates ?? settings.duplicatePrGateMode, - qualityGateMode: manifestGate?.readinessMode ?? settings.qualityGateMode, - qualityGateMinScore: manifestGate?.readinessMinScore ?? settings.qualityGateMinScore ?? null, + linkedIssueGateMode: settings.linkedIssueGateMode, + duplicatePrGateMode: settings.duplicatePrGateMode, + qualityGateMode: settings.qualityGateMode, + qualityGateMinScore: settings.qualityGateMinScore ?? null, readinessScore: readinessScore ?? null, confirmedContributor, }; } +/** + * 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 []; @@ -860,13 +865,10 @@ async function maybePublishPrPublicSurface( webhook: { deliveryId: string; authorType?: string | undefined; action?: string | undefined }, ): Promise { const author = pr.authorLogin ?? null; - // `.gittensory.yml` authoritatively controls the gate (yml > DB settings > defaults). It is loaded only - // when the gate is enabled in settings (so dormant repos stay network-free), then it refines the blocker - // policy and may DISABLE the gate (gate.enabled: false). It only chooses what the gate does — - // confirmedContributor still governs WHO can be blocked, downstream in evaluateGateCheck. - const gateDbEnabled = settings.gateCheckMode === "enabled" && Boolean(advisory.headSha); - const manifestGate = gateDbEnabled ? (await loadRepoFocusManifest(env, repoFullName)).gate : undefined; - const gateEnabled = gateDbEnabled && manifestGate?.enabled !== false; + // `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({ settings, @@ -983,16 +985,14 @@ 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 = gateEnabled - ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor, manifestGate)) - : undefined; + const gateEvaluation = gateEnabled ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor)) : undefined; if (gateEnabled) { const gateCheckResult = await createOrUpdateGateCheckRun( env, installationId, repoFullName, advisory, - gateCheckPolicy(settings, readiness.total, confirmedContributor, manifestGate), + gateCheckPolicy(settings, readiness.total, confirmedContributor), { checkRunId: pendingGateCheckRunId, }, @@ -1178,7 +1178,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; @@ -1369,7 +1369,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/focus-manifest-loader.ts b/src/signals/focus-manifest-loader.ts index 2e0e739161..0774106420 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 { gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, type FocusManifest, type FocusManifestSource } from "./focus-manifest"; +import { gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, 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; } @@ -180,6 +181,7 @@ function manifestToJson(manifest: FocusManifest): Record { maintainerNotes: manifest.maintainerNotes, publicNotes: manifest.publicNotes, gate: gateConfigToJson(manifest.gate), + settings: settingsOverrideToJson(manifest.settings), }; } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index c01658b979..0b4b90fdf6 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -1,5 +1,5 @@ import { parse as parseYaml } from "yaml"; -import type { GateRuleMode, JsonValue } from "../types"; +import type { GateRuleMode, JsonValue, RepositorySettings } from "../types"; export type FocusManifestSource = "repo_file" | "api_record" | "none"; export type FocusManifestLinkedIssuePolicy = "required" | "preferred" | "optional"; @@ -23,6 +23,37 @@ export type FocusManifestGateConfig = { 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" + > +>; + /** * 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 @@ -41,6 +72,7 @@ export type FocusManifest = { maintainerNotes: string[]; publicNotes: string[]; gate: FocusManifestGateConfig; + settings: FocusManifestSettings; warnings: string[]; }; @@ -100,6 +132,7 @@ const EMPTY_MANIFEST: FocusManifest = { maintainerNotes: [], publicNotes: [], gate: { ...EMPTY_GATE_CONFIG }, + settings: {}, warnings: [], }; @@ -112,7 +145,7 @@ export function isFocusManifestPublicSafe(text: string): boolean { } function emptyManifest(source: FocusManifestSource, warnings: string[] = []): FocusManifest { - return { ...EMPTY_MANIFEST, source, warnings, gate: { ...EMPTY_GATE_CONFIG } }; + return { ...EMPTY_MANIFEST, source, warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {} }; } function normalizeStringList(value: JsonValue | undefined, field: string, warnings: string[]): string[] { @@ -231,6 +264,86 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { 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; +} + +/** + * 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 @@ -255,6 +368,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): maintainerNotes: normalizeStringList(record.maintainerNotes, "maintainerNotes", warnings), publicNotes: normalizeStringList(record.publicNotes, "publicNotes", warnings).filter(isFocusManifestPublicSafe), gate: parseGateConfig(record.gate, warnings), + settings: parseSettingsOverride(record.settings, warnings), warnings, }; if ( @@ -266,7 +380,8 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): manifest.publicNotes.length === 0 && manifest.linkedIssuePolicy === "optional" && manifest.issueDiscoveryPolicy === "neutral" && - !manifest.gate.present + !manifest.gate.present && + Object.keys(manifest.settings).length === 0 ) { 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 c8d24cee52..63a205a303 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -8,8 +8,11 @@ import { matchesManifestPath, parseFocusManifest, parseFocusManifestContent, + resolveEffectiveSettings, + settingsOverrideToJson, type FocusManifest, } from "../../src/signals/focus-manifest"; +import type { RepositorySettings } from "../../src/types"; const FULL_MANIFEST = { source: "repo_file", @@ -397,6 +400,7 @@ describe("compileFocusManifestPolicy", () => { maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], gate: { present: false, enabled: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null }, + settings: {}, warnings: [], }); expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused."); @@ -749,3 +753,86 @@ describe("parseFocusManifest gate config", () => { 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: + }); +}); diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index dcafaedce3..2f6aae0777 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest"; import { gateCheckPolicy } from "../../src/queue/processors"; import { evaluateGateCheck } from "../../src/rules/advisory"; -import type { FocusManifestGateConfig } from "../../src/signals/focus-manifest"; +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", @@ -14,10 +16,6 @@ function settings(over: Partial = {}): RepositorySettings { } as unknown as RepositorySettings; } -function gate(over: Partial = {}): FocusManifestGateConfig { - return { present: true, enabled: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, ...over }; -} - function missingIssueAdvisory(): Advisory { return { id: "advisory-policy", @@ -35,50 +33,52 @@ function missingIssueAdvisory(): Advisory { }; } -describe("gateCheckPolicy precedence (.gittensory.yml gate config > DB settings)", () => { - it("uses DB settings when no manifest gate config is provided", () => { - const policy = gateCheckPolicy(settings({ linkedIssueGateMode: "block" }), 80, true); - expect(policy.linkedIssueGateMode).toBe("block"); - expect(policy.duplicatePrGateMode).toBe("block"); - expect(policy.qualityGateMode).toBe("advisory"); - expect(policy.readinessScore).toBe(80); - expect(policy.confirmedContributor).toBe(true); +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("lets the manifest authoritatively override each blocker mode over DB settings", () => { - const policy = gateCheckPolicy( - settings({ linkedIssueGateMode: "advisory", duplicatePrGateMode: "block", qualityGateMode: "off", qualityGateMinScore: 10 }), - 55, - true, - gate({ linkedIssue: "block", duplicates: "off", readinessMode: "block", readinessMinScore: 70 }), + 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(policy.linkedIssueGateMode).toBe("block"); // manifest "block" beats DB "advisory" - expect(policy.duplicatePrGateMode).toBe("off"); // manifest "off" beats DB "block" - expect(policy.qualityGateMode).toBe("block"); // manifest readiness.mode - expect(policy.qualityGateMinScore).toBe(70); // manifest readiness.minScore + 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("falls back to DB per-field when only some manifest fields are set", () => { - const policy = gateCheckPolicy(settings({ linkedIssueGateMode: "advisory", duplicatePrGateMode: "block" }), null, false, gate({ linkedIssue: "block" })); - expect(policy.linkedIssueGateMode).toBe("block"); // overridden by manifest - expect(policy.duplicatePrGateMode).toBe("block"); // falls back to DB - expect(policy.qualityGateMode).toBe("advisory"); // falls back to DB - expect(policy.confirmedContributor).toBe(false); + 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 blocked = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(settings({ linkedIssueGateMode: "advisory" }), null, true, gate({ linkedIssue: "block" }))); + 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 relieved = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(settings({ linkedIssueGateMode: "block" }), null, true, gate({ linkedIssue: "advisory" }))); - expect(relieved.conclusion).toBe("success"); + 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 manifest config", () => { - const nonConfirmed = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(settings({ linkedIssueGateMode: "advisory" }), null, false, gate({ linkedIssue: "block" }))); + 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 88ed6f8e13..37abcaa861 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -1487,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", @@ -1616,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", @@ -2093,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", @@ -2611,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, { From 56061db62d5550637551a6062f112c67262eda80 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:02:35 -0700 Subject: [PATCH 4/4] feat(github): maintainer review-content overrides via .gittensory.yml (review:) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `review:` block so maintainers control the public review-panel CONTENT from config: - `review.footer.text` — custom footer lead copy; the Gittensor register link + Gittensory attribution are ALWAYS appended (the growth surface is preserved). - `review.note` — a custom intro line shown in the panel. - `review.fields: { : false }` — show/hide individual panel rows (linkedIssue, relatedWork, reviewLoad, validationEvidence, openPrQueue, contributorContext, gateResult). Maintainer-supplied text (footer/note) is validated public-safe (forbidden reward/score/wallet/ hotkey terms + local paths) at parse time and dropped if unsafe — never published. Resolved and threaded into the full panel + the minimal-invite comment; manifest is cached (a DB read after the settings resolution already loaded it). Tests: review parse (footer/fields/note, unsafe-rejected, invalid/non-mapping, round-trip) plus an end-to-end panel render (custom footer + mandatory attribution kept, intro note shown, hidden row absent). CONTRIBUTING documents the block. typecheck + 97% coverage hold. --- CONTRIBUTING.md | 10 +++- src/github/footer.ts | 12 ++++- src/queue/processors.ts | 5 +- src/signals/engine.ts | 37 +++++++------ src/signals/focus-manifest-loader.ts | 3 +- src/signals/focus-manifest.ts | 77 +++++++++++++++++++++++++++- test/unit/focus-manifest.test.ts | 39 ++++++++++++++ test/unit/signals-coverage.test.ts | 18 +++++++ 8 files changed, 178 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 787d9bbd3d..ce58d93371 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -201,11 +201,17 @@ Config as code (`.gittensory.yml`) — every repository setting is controllable `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); it never - changes **who** can be blocked — only confirmed Gittensor contributors are ever hard-blocked. +- 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/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 16daeb7194..368f0d55c9 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1047,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); 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 0774106420..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 { gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, settingsOverrideToJson, 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"; @@ -182,6 +182,7 @@ function manifestToJson(manifest: FocusManifest): Record { 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 0b4b90fdf6..3ef6889da5 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -54,6 +54,24 @@ export type FocusManifestSettings = Partial< > >; +/** 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 @@ -73,6 +91,7 @@ export type FocusManifest = { publicNotes: string[]; gate: FocusManifestGateConfig; settings: FocusManifestSettings; + review: FocusManifestReviewConfig; warnings: string[]; }; @@ -133,6 +152,7 @@ const EMPTY_MANIFEST: FocusManifest = { publicNotes: [], gate: { ...EMPTY_GATE_CONFIG }, settings: {}, + review: { present: false, footerText: null, note: null, fields: {} }, warnings: [], }; @@ -145,7 +165,7 @@ export function isFocusManifestPublicSafe(text: string): boolean { } function emptyManifest(source: FocusManifestSource, warnings: string[] = []): FocusManifest { - return { ...EMPTY_MANIFEST, source, warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {} }; + 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[] { @@ -327,6 +347,57 @@ export function settingsOverrideToJson(settings: FocusManifestSettings): JsonVal 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 @@ -369,6 +440,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): 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 ( @@ -381,7 +453,8 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): manifest.linkedIssuePolicy === "optional" && manifest.issueDiscoveryPolicy === "neutral" && !manifest.gate.present && - Object.keys(manifest.settings).length === 0 + 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.test.ts b/test/unit/focus-manifest.test.ts index 63a205a303..de34a61e6d 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -9,6 +9,7 @@ import { parseFocusManifest, parseFocusManifestContent, resolveEffectiveSettings, + reviewConfigToJson, settingsOverrideToJson, type FocusManifest, } from "../../src/signals/focus-manifest"; @@ -401,6 +402,7 @@ describe("compileFocusManifestPolicy", () => { 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."); @@ -836,3 +838,40 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = 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/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" },