diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx index 15bf0bc723..dd446546f9 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx @@ -18,6 +18,7 @@ import { type Status, } from "@/components/site/control-primitives"; import { AiReviewSettings } from "@/components/site/app-panels/ai-review-settings"; +import { MaintainerSettings } from "@/components/site/app-panels/maintainer-settings"; import { StatCard } from "@/components/site/primitives"; import { EmptyState, LoadingState, StateBoundary } from "@/components/site/state-views"; import { apiFetch } from "@/lib/api/request"; @@ -338,6 +339,8 @@ function MaintainerDashboardView() { + + ) : null} diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx new file mode 100644 index 0000000000..05edfeaa5b --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-settings.tsx @@ -0,0 +1,676 @@ +import { FileCog, Loader2, Save } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { StatusPill } from "@/components/site/control-primitives"; +import { apiFetch } from "@/lib/api/request"; +import { getApiOrigin } from "@/lib/api/origin"; +import { extractPreviewRepoOptions, splitRepoFullName } from "@/lib/maintainer-settings-preview"; + +type GateMode = "off" | "advisory" | "block"; +type CommandRole = "maintainer" | "collaborator" | "pr_author" | "confirmed_miner"; + +type CommandAuthorization = { + default?: CommandRole[]; + commands?: Record; +}; + +type MaintainerSettings = { + commentMode: "off" | "detected_contributors_only" | "all_prs"; + publicAudienceMode: "oss_maintainer" | "gittensor_only"; + publicSignalLevel: "minimal" | "standard"; + publicSurface: "off" | "comment_and_label" | "comment_only" | "label_only"; + checkRunMode: "off" | "enabled"; + checkRunDetailLevel: "minimal" | "standard" | "deep"; + gateCheckMode: "off" | "enabled"; + gatePack: "gittensor" | "oss-anti-slop"; + linkedIssueGateMode: GateMode; + duplicatePrGateMode: GateMode; + qualityGateMode: GateMode; + qualityGateMinScore: number | null; + mergeReadinessGateMode: GateMode; + manifestPolicyGateMode: GateMode; + firstTimeContributorGrace: boolean; + slopGateMode: GateMode; + slopGateMinScore: number | null; + slopAiAdvisory: boolean; + autoLabelEnabled: boolean; + gittensorLabel: string; + createMissingLabel: boolean; + includeMaintainerAuthors: boolean; + requireLinkedIssue: boolean; + badgeEnabled: boolean; + commandAuthorization: CommandAuthorization; +}; + +type Message = { kind: "ok" | "err"; text: string }; + +const GATE_MODE_OPTIONS: Array<[GateMode, string]> = [ + ["off", "off"], + ["advisory", "advisory"], + ["block", "block"], +]; + +const COMMAND_ROLES: Array<[CommandRole, string]> = [ + ["maintainer", "maintainer"], + ["collaborator", "collaborator"], + ["pr_author", "PR author"], + ["confirmed_miner", "confirmed miner"], +]; + +// The maintainer-editable subset, sent verbatim to PUT /settings (which merges onto current settings). +const EDITABLE_KEYS: Array = [ + "commentMode", + "publicAudienceMode", + "publicSignalLevel", + "publicSurface", + "checkRunMode", + "checkRunDetailLevel", + "gateCheckMode", + "gatePack", + "linkedIssueGateMode", + "duplicatePrGateMode", + "qualityGateMode", + "qualityGateMinScore", + "mergeReadinessGateMode", + "manifestPolicyGateMode", + "firstTimeContributorGrace", + "slopGateMode", + "slopGateMinScore", + "slopAiAdvisory", + "autoLabelEnabled", + "gittensorLabel", + "createMissingLabel", + "includeMaintainerAuthors", + "requireLinkedIssue", + "badgeEnabled", + "commandAuthorization", +]; + +type SelectFieldDef = { + key: keyof MaintainerSettings; + label: string; + kind: "select"; + options: Array<[string, string]>; +}; +type ToggleFieldDef = { + key: keyof MaintainerSettings; + label: string; + kind: "toggle"; + hint?: string; +}; +type NumberFieldDef = { + key: keyof MaintainerSettings; + label: string; + kind: "number"; + placeholder?: string; +}; +type FieldDef = SelectFieldDef | ToggleFieldDef | NumberFieldDef; + +const GATE_FIELDS: FieldDef[] = [ + { + key: "gateCheckMode", + label: "Gate check", + kind: "select", + options: [ + ["off", "off"], + ["enabled", "enabled"], + ], + }, + { + key: "gatePack", + label: "Policy pack", + kind: "select", + options: [ + ["gittensor", "gittensor (confirmed-only)"], + ["oss-anti-slop", "oss-anti-slop (any author)"], + ], + }, + { + key: "mergeReadinessGateMode", + label: "Merge-readiness (master)", + kind: "select", + options: GATE_MODE_OPTIONS, + }, + { key: "linkedIssueGateMode", label: "Linked issue", kind: "select", options: GATE_MODE_OPTIONS }, + { key: "duplicatePrGateMode", label: "Duplicate PR", kind: "select", options: GATE_MODE_OPTIONS }, + { + key: "qualityGateMode", + label: "Quality / readiness", + kind: "select", + options: GATE_MODE_OPTIONS, + }, + { + key: "qualityGateMinScore", + label: "Quality min score", + kind: "number", + placeholder: "default", + }, + { + key: "manifestPolicyGateMode", + label: "Focus-manifest policy", + kind: "select", + options: GATE_MODE_OPTIONS, + }, + { + key: "firstTimeContributorGrace", + label: "First-time-contributor grace", + kind: "toggle", + hint: "Soften a newcomer's block to advisory", + }, +]; + +const SLOP_FIELDS: FieldDef[] = [ + { key: "slopGateMode", label: "Slop gate", kind: "select", options: GATE_MODE_OPTIONS }, + { + key: "slopGateMinScore", + label: "Slop min score", + kind: "number", + placeholder: "60 (high band)", + }, + { + key: "slopAiAdvisory", + label: "AI slop advisory", + kind: "toggle", + hint: "Append an AI-assisted advisory note", + }, +]; + +const SURFACE_FIELDS: FieldDef[] = [ + { + key: "commentMode", + label: "Comment mode", + kind: "select", + options: [ + ["off", "off"], + ["detected_contributors_only", "detected contributors only"], + ["all_prs", "all PRs"], + ], + }, + { + key: "publicSurface", + label: "Public surface", + kind: "select", + options: [ + ["off", "off"], + ["comment_and_label", "comment + label"], + ["comment_only", "comment only"], + ["label_only", "label only"], + ], + }, + { + key: "publicSignalLevel", + label: "Public signal level", + kind: "select", + options: [ + ["minimal", "minimal"], + ["standard", "standard"], + ], + }, + { + key: "publicAudienceMode", + label: "Audience", + kind: "select", + options: [ + ["oss_maintainer", "OSS maintainer"], + ["gittensor_only", "gittensor only"], + ], + }, + { + key: "checkRunMode", + label: "Context check run", + kind: "select", + options: [ + ["off", "off"], + ["enabled", "enabled"], + ], + }, + { + key: "checkRunDetailLevel", + label: "Check detail", + kind: "select", + options: [ + ["minimal", "minimal"], + ["standard", "standard"], + ["deep", "deep"], + ], + }, + { key: "includeMaintainerAuthors", label: "Include maintainer-authored PRs", kind: "toggle" }, + { key: "requireLinkedIssue", label: "Require a linked issue", kind: "toggle" }, + { key: "badgeEnabled", label: "Repo badge", kind: "toggle" }, +]; + +function repoApiBase(repoFullName: string): string | null { + const target = splitRepoFullName(repoFullName); + if (!target) return null; + return `${getApiOrigin().replace(/\/$/, "")}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`; +} + +const JSON_HEADERS = { Accept: "application/json", "Content-Type": "application/json" }; +const FIELD_CLASS = + "mt-1 min-h-10 w-full rounded-token border border-border bg-background/70 px-3 py-2 font-mono text-token-sm text-foreground outline-none transition-colors focus:border-mint"; +const LABEL_CLASS = "font-mono text-token-2xs uppercase tracking-wider text-muted-foreground"; + +/** + * Maintainer self-serve editor for the per-repo gate / slop / label / surface / command-authorization + * settings (#130). Loads GET /settings, saves a merge via PUT /settings; the focus manifest has its own + * load/save against /focus-manifest. The secret AI key and operator-only scoring internals are not editable + * here — they live on the AI-review panel and operator surfaces respectively. + */ +export function MaintainerSettings({ reviewability }: { reviewability: Array<{ pr: string }> }) { + const repoOptions = useMemo(() => extractPreviewRepoOptions(reviewability), [reviewability]); + const [repoFullName, setRepoFullName] = useState(repoOptions[0] ?? ""); + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + const base = repoApiBase(repoFullName); + const hasRepos = repoOptions.length > 0; + + const load = useCallback(async () => { + const apiBase = repoApiBase(repoFullName); + if (!apiBase) return; + setMessage(null); + setLoading(true); + const result = await apiFetch(`${apiBase}/settings`, { + label: "Repository settings", + credentials: "include", + silentStatus: true, + }); + setSettings(result.ok ? result.data : null); + setLoading(false); + }, [repoFullName]); + + useEffect(() => { + void load(); + }, [load]); + + function setField(key: K, value: MaintainerSettings[K]) { + setSettings((current) => (current ? { ...current, [key]: value } : current)); + } + + async function save() { + if (!base || !settings) return; + setBusy(true); + const payload = Object.fromEntries(EDITABLE_KEYS.map((key) => [key, settings[key]])); + const result = await apiFetch(`${base}/settings`, { + method: "PUT", + label: "Save repository settings", + credentials: "include", + headers: JSON_HEADERS, + body: JSON.stringify(payload), + }); + setBusy(false); + if (result.ok) { + setSettings(result.data); + setMessage({ kind: "ok", text: "Settings saved." }); + } else { + setMessage({ kind: "err", text: result.message }); + } + } + + const defaultRoles = settings?.commandAuthorization?.default ?? []; + const commandOverrides = Object.entries(settings?.commandAuthorization?.commands ?? {}); + + return ( +
+
+
+

+ Repository settings +

+

+ Configure exactly what Gittensory enforces and surfaces on this repo — gate modes, + anti-slop, labels, public output, and who can run each command. Changes are audited. +

+
+ {settings ? ( + + gate {settings.gateCheckMode} + + ) : null} +
+ + + + {loading ? ( +

+ Loading settings… +

+ ) : settings ? ( +
+ + +
+

Labels

+
+ setField("autoLabelEnabled", v)} + /> + + setField("createMissingLabel", v)} + /> +
+
+ + +
+

Command authorization

+

+ Default roles allowed to run any @gittensory{" "} + command. Per-command overrides (edited via the focus manifest) are shown below. +

+
+ {COMMAND_ROLES.map(([role, roleLabel]) => ( + + ))} +
+ {commandOverrides.length > 0 ? ( +
+ {commandOverrides.map(([command, roles]) => ( +
+
{command}
+
{roles.join(", ")}
+
+ ))} +
+ ) : null} +
+ +
+ + + {message?.text ?? ""} + +
+ + +
+ ) : ( +

+ {hasRepos + ? "Settings are unavailable for this repository." + : "Enter an installed repository to configure it."} +

+ )} +
+ ); +} + +function FieldGroup({ + title, + fields, + settings, + setField, +}: { + title: string; + fields: FieldDef[]; + settings: MaintainerSettings; + setField: (key: K, value: MaintainerSettings[K]) => void; +}) { + return ( +
+

{title}

+
+ {fields.map((field) => { + if (field.kind === "toggle") { + return ( + + setField(field.key, value as MaintainerSettings[typeof field.key]) + } + /> + ); + } + if (field.kind === "number") { + const raw = settings[field.key] as number | null; + return ( + + ); + } + return ( + + ); + })} +
+
+ ); +} + +function ToggleControl({ + label, + hint, + value, + onChange, +}: { + label: string; + hint?: string; + value: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + ); +} + +type FocusManifestResponse = { manifest: unknown }; + +/** + * Edit the repo's focus manifest as JSON. The manifest is repo-public config-as-code (it mirrors + * `.gittensory.yml`); this surface lets a maintainer edit the API-record copy without committing a file. + */ +function FocusManifestEditor({ base }: { base: string | null }) { + const [text, setText] = useState(""); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + const load = useCallback(async () => { + if (!base) return; + setLoading(true); + setMessage(null); + const result = await apiFetch(`${base}/focus-manifest`, { + label: "Focus manifest", + credentials: "include", + silentStatus: true, + }); + setText(result.ok ? JSON.stringify(result.data.manifest, null, 2) : ""); + setLoading(false); + }, [base]); + + useEffect(() => { + void load(); + }, [load]); + + async function save() { + if (!base) return; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + setMessage({ kind: "err", text: "Manifest must be valid JSON." }); + return; + } + setBusy(true); + const result = await apiFetch(`${base}/focus-manifest`, { + method: "PUT", + label: "Save focus manifest", + credentials: "include", + headers: JSON_HEADERS, + body: JSON.stringify(parsed), + }); + setBusy(false); + if (result.ok) { + setText(JSON.stringify(result.data.manifest, null, 2)); + setMessage({ kind: "ok", text: "Focus manifest saved." }); + } else { + setMessage({ kind: "err", text: result.message }); + } + } + + return ( +
+

+ Focus manifest (config-as-code) +

+

+ The repo's maintainer focus policy as JSON — wanted/blocked paths, linked-issue policy, + test expectations, and gate overrides. Mirrors{" "} + .gittensory.yml. +

+