diff --git a/README.md b/README.md index 1810d4e17..acd8b655a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,9 @@ Or by hand: 3. **Add a policy file.** Commit `.corbits-triage.yml` to the repository's default branch, press **Start a policy file** in the portal to open a pull request with starter rules, or give your coding agent the Configure Prompt - inside the repository. The format is in + inside the repository. Change rules, judging and code review later from the + portal's **Code review** page, which opens a pull request for each batch. + The format is in [Policy file](docs/PRODUCT.md#policy-file). **Configure Prompt** diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3141c3c43..4165ecd8a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -26,6 +26,7 @@ import Inbox from "./pages/Inbox.tsx"; import PRDetail from "./pages/PRDetail.tsx"; import Pulse from "./pages/Pulse.tsx"; import Repositories from "./pages/Repositories.tsx"; +import CodeReview from "./pages/CodeReview.tsx"; import Settings from "./pages/Settings.tsx"; function InboxRedirect() { @@ -173,6 +174,7 @@ function Gate() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/cards.css b/apps/web/src/cards.css index 2923e45c3..6161e3f59 100644 --- a/apps/web/src/cards.css +++ b/apps/web/src/cards.css @@ -54,3 +54,17 @@ .settings .sentence { flex-wrap: wrap; } .settings .sentence .txt { flex-basis: auto; } } + +.settings .rule.off { color: var(--ink-mute); } +.settings .pending-dot { display: inline-block; width: 8px; height: 8px; margin-left: 8px; border-radius: 50%; background: var(--orange); vertical-align: middle; } +.settings .tray { position: sticky; bottom: 12px; margin-top: 24px; padding: 16px; background: var(--white); border: 1px solid var(--rule-strong); border-radius: var(--r-md); } +.settings .tray ul { margin: 8px 0; padding-left: 18px; } +.settings .file-pair { display: grid; gap: 12px; margin: 8px 0; } +.settings .file-text { white-space: pre-wrap; font: 12px/1.4 var(--mono); background: var(--fill); padding: 8px; overflow: auto; max-height: 240px; } +.settings .repo-switch { position: relative; max-width: 420px; } +.settings .repo-switch ul { position: absolute; z-index: 2; left: 0; right: 0; margin: 4px 0 0; padding: 4px; list-style: none; background: var(--white); border: 1px solid var(--rule); border-radius: var(--r-md); max-height: 280px; overflow: auto; } +.settings .repo-switch li button { width: 100%; text-align: left; border: 0; background: none; font: inherit; padding: 6px 8px; cursor: pointer; } +.settings .repo-switch li button.on, .settings .repo-switch li button:hover { background: var(--fill-hover); } +.settings .judge-form { display: grid; gap: 8px; margin-top: 8px; } +.settings .judge-form .ask { display: grid; gap: 6px; } +.settings .judge-form fieldset { border: 0; padding: 0; display: grid; gap: 4px; } diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx index df89a44d2..b400f542a 100644 --- a/apps/web/src/components/Layout.tsx +++ b/apps/web/src/components/Layout.tsx @@ -13,7 +13,7 @@ import { PulseProvider } from "../lib/pulse-visit.tsx"; import { triageEnabledRepos } from "../lib/repo-rows.ts"; import { createHubTransport } from "../lib/hub-transport.ts"; import { useSession } from "../lib/session.tsx"; -import { CheckIcon, GearIcon, InboxIcon, ListIcon, MergeIcon, PulseIcon, RepoIcon, SignOutIcon, UpDownIcon } from "./inbox-icons.tsx"; +import { CheckIcon, GearIcon, InboxIcon, ListIcon, MergeIcon, PulseIcon, RepoIcon, ReviewIcon, SignOutIcon, UpDownIcon } from "./inbox-icons.tsx"; function WorkspaceSwitcher() { const { snapshot, warn } = usePortal(); @@ -119,7 +119,7 @@ function roomOf(path: string, pullPage: boolean) { if (path.startsWith("/settings")) return "settings"; if (path.startsWith("/repositories")) return "repos"; if (pullPage) return "pr"; - if (path.startsWith("/pulse")) return "pulse"; + if (path.startsWith("/pulse") || path.startsWith("/code-review")) return "pulse"; return "inbox"; } @@ -158,10 +158,14 @@ export default function Layout({ children }: { children: ReactNode }) { Corbits Triage - + Pulse + + + Code review +

Inbox

@@ -200,6 +204,10 @@ export default function Layout({ children }: { children: ReactNode }) { All open + + + Review + Repos diff --git a/apps/web/src/components/code-review/PolicyEditor.dom.test.tsx b/apps/web/src/components/code-review/PolicyEditor.dom.test.tsx new file mode 100644 index 000000000..20bf7ff0d --- /dev/null +++ b/apps/web/src/components/code-review/PolicyEditor.dom.test.tsx @@ -0,0 +1,187 @@ +import { afterEach, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { emptyPack, type PolicyPatchChange } from "@corbits/triage-contracts"; +import { PolicyEditor } from "./PolicyEditor.tsx"; + +let root: Root | undefined; + +afterEach(function unmount() { + act(() => root?.unmount()); + document.body.innerHTML = ""; +}); + +test("a switch becomes a change in the tray and the preview shows the file before and after", async () => { + const pack = emptyPack("acme/widgets"); + pack.checks["ci-green"] = { enabled: true, text: "CI passes.", reason: "Required.", evidence: ["maintainer-stated"] }; + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root?.render( + +
+ +
+
, + ); + }); + const focused = [...document.querySelectorAll("[role=switch]")].find((node) => node.getAttribute("aria-label")?.includes("focused")); + expect(focused).toBeTruthy(); + await act(async () => { + focused?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(document.body.textContent).toContain("1 change"); + expect(document.body.textContent).toContain("Turn on"); + expect(document.body.textContent).toContain("rules: before"); + expect(document.body.textContent).toContain("rules: after"); +}); + +function switchNamed(label: string): HTMLElement { + const found = [...document.querySelectorAll("[role=switch]")].find((node) => node.getAttribute("aria-label") === label); + if (!(found instanceof HTMLElement)) throw new Error(label); + return found; +} + +function setField(label: string, value: string) { + const input = document.querySelector(`[aria-label="${label}"]`); + if (!(input instanceof HTMLInputElement)) throw new Error(label); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +test("a new size rule and forbidden paths wait for a value, then the batch enables the rule and sets the option", async () => { + const pack = emptyPack("acme/widgets"); + const seen: PolicyPatchChange[][] = []; + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root?.render( + +
+ change)); + return { before: "before", after: "after" }; + }} + onApply={async function apply() {}} + /> +
+
, + ); + }); + + await act(async () => { + switchNamed("Flag pull requests over any number of lines changed").click(); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(seen).toEqual([]); + expect(document.body.textContent).not.toContain("Open a pull request"); + expect(document.body.textContent).toContain("How many lines?"); + + await act(async () => { + setField("How many lines?", "400"); + }); + await act(async () => { + const use = [...document.querySelectorAll("button")].find((node) => node.textContent === "Use this"); + if (!(use instanceof HTMLButtonElement)) throw new Error("Use this"); + use.click(); + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + const lines = [ + { kind: "rule", id: "max-lines-changed", enabled: true }, + { kind: "rule-options", id: "max-lines-changed", options: { max: 400 } }, + ]; + expect(seen).toEqual([lines]); + + await act(async () => { + switchNamed("Never accept changes to forbidden paths").click(); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(seen).toEqual([lines]); + expect(document.body.textContent).toContain("Which paths?"); + + await act(async () => { + setField("Which paths?", "vendor/**, secrets/**"); + }); + await act(async () => { + const use = [...document.querySelectorAll("button")].find((node) => node.textContent === "Use this"); + if (!(use instanceof HTMLButtonElement)) throw new Error("Use this"); + use.click(); + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(seen[1]).toEqual([ + ...lines, + { kind: "rule", id: "no-forbidden-paths", enabled: true }, + { kind: "rule-options", id: "no-forbidden-paths", options: { paths: ["vendor/**", "secrets/**"] } }, + ]); +}); + +test("a size rule already in the file toggles with only enabled", async () => { + const pack = emptyPack("acme/widgets"); + pack.checks["max-lines-changed"] = { enabled: false, maxLines: 400 }; + const seen: PolicyPatchChange[][] = []; + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root?.render( + +
+ change)); + return { before: "before", after: "after" }; + }} + onApply={async function apply() {}} + /> +
+
, + ); + }); + await act(async () => { + switchNamed("Flag pull requests over 400 lines changed").click(); + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(document.body.textContent).not.toContain("How many lines?"); + expect(seen).toEqual([[{ kind: "rule", id: "max-lines-changed", enabled: true }]]); +}); diff --git a/apps/web/src/components/code-review/PolicyEditor.tsx b/apps/web/src/components/code-review/PolicyEditor.tsx new file mode 100644 index 000000000..84b346cb8 --- /dev/null +++ b/apps/web/src/components/code-review/PolicyEditor.tsx @@ -0,0 +1,251 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Input, Switch } from "@corbits/react-ui"; +import { errorText } from "@corbits/common"; +import { CUSTOM_CHECK_CAP, type CheckPack, type PolicyPatchChange } from "@corbits/triage-contracts"; +import { counted } from "../../lib/plural.ts"; +import { FAIL_SENTENCES, absentValue, batchReady, changeSentences, codeReviewOn, judgeRuleChange, judgingOn, ruleRows, toggleCodeReview, toggleRule, withRequiredValue, type FailWord, type RuleLane, type RuleRow } from "../../lib/policy-edits.ts"; + +export type FilePreview = { before: string; after: string }; + +const LANES: Record = { checks: "Checks", judging: "Judging" }; + +const FAIL_CHOICES: Array<{ word: FailWord; label: string }> = [ + { word: "fail", label: "Fail the pull request" }, + { word: "ask", label: "Ask a maintainer" }, + { word: "label", label: "Label it and leave the verdict" }, +]; + +type PolicyEditorProps = { + repo: string; + pack: CheckPack; + reviewOn: boolean; + hasModel: boolean; + hasPlace: boolean; + readOnly: boolean; + touched: readonly string[]; + applying: boolean; + applyError: string; + preview: (changes: readonly PolicyPatchChange[]) => Promise; + onApply: (changes: readonly PolicyPatchChange[]) => Promise; +}; + +function passSentence(claim: string): string { + return claim.trim() ? `It passes when it is true that ${claim.trim()}.` : "It passes when that is true."; +} + +function JudgeRuleForm({ pack, changes, disabled, onAdd }: { pack: CheckPack; changes: readonly PolicyPatchChange[]; disabled: boolean; onAdd: (change: PolicyPatchChange) => void }) { + const [step, setStep] = useState(0); + const [claim, setClaim] = useState(""); + const [onFail, setOnFail] = useState("fail"); + const full = pack.custom.length + changes.filter((change) => change.kind === "add-rule").length >= CUSTOM_CHECK_CAP; + const preview = claim.trim() ? `Is it true that ${claim.trim()}?` : "Is it true that …?"; + + function add() { + const change = judgeRuleChange(pack, changes, claim, onFail); + if (!change) return; + onAdd(change); + setStep(0); + setClaim(""); + setOnFail("fail"); + } + + if (full) return

This repository already has {CUSTOM_CHECK_CAP} custom rules.

; + + return ( +
+

{step < 1 ? preview : passSentence(claim)} {step > 1 ? FAIL_SENTENCES[onFail] : ""}

+ {step === 0 ? ( + + ) : null} + {step === 1 ?

When does it pass? {passSentence(claim)}

: null} + {step === 2 ? ( +
+ What happens on fail? + {FAIL_CHOICES.map((choice) => ( + + ))} +
+ ) : null} +
+ {step < 2 ? : } +
+
+ ); +} + +const VALUE_ASK: Record = { + "max-files-changed": "How many files?", + "max-lines-changed": "How many lines?", + "max-lines-changed-per-file": "How many lines in one file?", + "max-additions": "How many lines added?", + "max-deletions": "How many lines deleted?", + "no-forbidden-paths": "Which paths?", +}; + +function maxOf(draft: string): number | undefined { + const text = draft.trim(); + if (!/^[1-9]\d*$/.test(text)) return undefined; + return Number(text); +} + +function pathsOf(draft: string): string[] { + return draft.split(/[,\n]/).map((path) => path.trim()).filter((path) => path.length > 0); +} + +function ValueAsk({ id, draft, onDraft, onUse }: { id: string; draft: string; onDraft: (draft: string) => void; onUse: () => void }) { + const kind = id === "no-forbidden-paths" ? "paths" : "max"; + const label = VALUE_ASK[id] ?? ""; + const ready = kind === "max" ? maxOf(draft) !== undefined : pathsOf(draft).length > 0; + return ( +
+ + +
+ ); +} +function RuleSwitch({ row, pending, expanded, asking, draft, disabled, onFlip, onDraft, onUse }: { row: RuleRow; pending: boolean; expanded: boolean; asking: boolean; draft: string; disabled: boolean; onFlip: (next: boolean) => void; onDraft: (draft: string) => void; onUse: () => void }) { + const shown = row.on || asking; + return ( +
+
+ {row.sentence}{pending ? : null} + +
+ {expanded && asking ? : null} + {expanded && !asking ?

{row.needsModel ? "Needs a model" : `${row.sentence}.`}{row.needsModel ? <> Add a model : null}

: null} +
+ ); +} + +function Lane({ title, rows, touched, expanded, askId, draft, disabled, onFlip, onDraft, onUse }: { title: string; rows: RuleRow[]; touched: readonly string[]; expanded: string | null; askId: string | null; draft: string; disabled: boolean; onFlip: (id: string, next: boolean) => void; onDraft: (draft: string) => void; onUse: () => void }) { + return ( +
+

{title}

+
+ {rows.map((row) => ( + onFlip(row.id, next)} + onDraft={onDraft} + onUse={onUse} + /> + ))} +
+
+ ); +} + +/** The file as switches. Changes collect in the tray and leave as one pull request. */ +export function PolicyEditor({ repo, pack, reviewOn, hasModel, hasPlace, readOnly, touched, applying, applyError, preview, onApply }: PolicyEditorProps) { + const [changes, setChanges] = useState([]); + const [expanded, setExpanded] = useState(null); + const [askId, setAskId] = useState(null); + const [draft, setDraft] = useState(""); + const rows = ruleRows(pack, changes, hasModel); + const review = codeReviewOn(reviewOn, changes); + const on = rows.checks.filter((row) => row.on).length + rows.judging.filter((row) => row.on).length; + const ready = batchReady(pack, changes); + const shown = useQuery({ + queryKey: ["policy-patch-preview", repo, changes], + queryFn: function loadPreview() { + return preview(changes); + }, + enabled: changes.length > 0 && ready, + retry: false, + }); + + function flipRule(id: string, next: boolean) { + setExpanded(id); + if (next && absentValue(pack, id) !== undefined) { + setAskId(id); + setDraft(""); + setChanges((current) => toggleRule(pack, current, id, false)); + return; + } + setAskId(null); + setChanges((current) => toggleRule(pack, current, id, next)); + } + + function useValue() { + if (askId === null) return; + const field = absentValue(pack, askId); + if (field === "max") { + const max = maxOf(draft); + if (max === undefined) return; + setChanges((current) => withRequiredValue(current, askId, { max })); + } else if (field === "paths") { + const paths = pathsOf(draft); + if (paths.length === 0) return; + setChanges((current) => withRequiredValue(current, askId, { paths })); + } else { + return; + } + setAskId(null); + setDraft(""); + } + + function flipReview(next: boolean) { + setExpanded("code-review"); + setChanges((current) => toggleCodeReview(reviewOn, current, next)); + } + + function addRule(change: PolicyPatchChange) { + setChanges((current) => [...current, change]); + } + + async function open() { + await onApply(changes); + setChanges([]); + } + + const sentences = changeSentences(pack, changes); + + return ( +
+

{counted(on, "rule", "rules")} on. Judging is {judgingOn(rows.judging) ? "on" : "off"}. Code review is {review ? "on" : "off"}.

+ {(Object.keys(LANES) as RuleLane[]).map((lane) => )} +
+

Code review

+
+
+ Read each pull request for bugs{touched.includes("code-review") ? : null} + +
+ {expanded === "code-review" ? ( +

{hasPlace ? "Triage reads each new commit for bugs." : "Code review needs an isolated place to run."}{hasPlace ? null : <> Set one up}

+ ) : null} +
+
+
+

New judge rule

+ +
+ {changes.length > 0 && ready ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/code-review/RepoSwitcher.tsx b/apps/web/src/components/code-review/RepoSwitcher.tsx new file mode 100644 index 000000000..520d58d2f --- /dev/null +++ b/apps/web/src/components/code-review/RepoSwitcher.tsx @@ -0,0 +1,104 @@ +import { useState, type KeyboardEvent } from "react"; +import { Input } from "@corbits/react-ui"; +import type { RepoRecord } from "../../lib/hub-api.ts"; + +const RECENT = "code-review-recent"; + +export function repoKey(repo: RepoRecord): string { + return `${repo.source}:${repo.name}`; +} + +function readRecent(): string[] { + try { + const raw = localStorage.getItem(RECENT); + const parsed: unknown = raw === null ? [] : JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []; + } catch { + return []; + } +} + +function remember(key: string) { + const next = [key, ...readRecent().filter((item) => item !== key)].slice(0, 5); + localStorage.setItem(RECENT, JSON.stringify(next)); +} + +type RepoSwitcherProps = { repos: readonly RepoRecord[]; selected: RepoRecord | undefined; onSelect: (repo: RepoRecord) => void }; + +/** Search, arrow keys and the repositories opened lately. */ +export function RepoSwitcher({ repos, selected, onSelect }: RepoSwitcherProps) { + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [highlight, setHighlight] = useState(0); + const recent = readRecent(); + const needle = query.trim().toLowerCase(); + const matched = repos.filter((repo) => repo.name.toLowerCase().includes(needle)); + const listed = needle ? matched : [...matched].sort(function byRecent(a, b) { + function rank(key: string): number { + const at = recent.indexOf(key); + return at < 0 ? recent.length : at; + } + return rank(repoKey(a)) - rank(repoKey(b)) || a.name.localeCompare(b.name); + }); + function sharedName(repo: RepoRecord): boolean { + return repos.some((other) => other.name === repo.name && other.source !== repo.source); + } + + function choose(repo: RepoRecord) { + remember(repoKey(repo)); + setQuery(""); + setOpen(false); + onSelect(repo); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setOpen(true); + setHighlight((at) => Math.min(at + 1, Math.max(listed.length - 1, 0))); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setOpen(true); + setHighlight((at) => Math.max(at - 1, 0)); + return; + } + if (event.key === "Enter" && open && listed[highlight]) { + event.preventDefault(); + choose(listed[highlight]); + return; + } + if (event.key === "Escape") setOpen(false); + } + + function label(repo: RepoRecord): string { + return sharedName(repo) ? `${repo.name} (${repo.source === "github" ? "GitHub" : "Bitbucket"})` : repo.name; + } + + return ( +
+ setOpen(true)} + onChange={(event) => { setQuery(event.target.value); setOpen(true); setHighlight(0); }} + onKeyDown={onKeyDown} + /> + {open ? ( +
    + {listed.map((repo, at) => ( +
  • + +
  • + ))} + {listed.length === 0 ?
  • No repository matches.
  • : null} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/inbox-icons.tsx b/apps/web/src/components/inbox-icons.tsx index bbd1190ce..11d22a022 100644 --- a/apps/web/src/components/inbox-icons.tsx +++ b/apps/web/src/components/inbox-icons.tsx @@ -36,6 +36,9 @@ export function ListIcon() { export function MergeIcon() { return ; } +export function ReviewIcon() { + return ; +} export function RepoIcon() { return ; } diff --git a/apps/web/src/components/repo-panel/CodeReviewCard.tsx b/apps/web/src/components/repo-panel/CodeReviewCard.tsx deleted file mode 100644 index f9ec7df16..000000000 --- a/apps/web/src/components/repo-panel/CodeReviewCard.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Button } from "@corbits/react-ui"; -import { Link } from "react-router-dom"; -import { usePortal } from "../../lib/portal.tsx"; - -/** Code review is set in the repository's policy file; this card only says when the server has nowhere to run it. */ -export default function CodeReviewCard() { - const { snapshot } = usePortal(); - if (snapshot?.codeReviewPlace !== null) return null; - return ( -
-

Code review

-
-
- Code review needs an isolated place to run - Each review runs apart from everything else, and this server has no such place set up yet. -
-
- -
-
-
- ); -} diff --git a/apps/web/src/components/repo-panel/RepoPanel.tsx b/apps/web/src/components/repo-panel/RepoPanel.tsx index 83907af3a..4f8e6a951 100644 --- a/apps/web/src/components/repo-panel/RepoPanel.tsx +++ b/apps/web/src/components/repo-panel/RepoPanel.tsx @@ -10,7 +10,6 @@ import type { RepoRow } from "../../lib/repo-rows.ts"; import { CloseIcon, ExternalIcon } from "../inbox-icons.tsx"; import { hasRuleOn } from "../../lib/pack-draft.ts"; import BitbucketDatacenterWebhook from "./BitbucketDatacenterWebhook.tsx"; -import CodeReviewCard from "./CodeReviewCard.tsx"; import OutcomesCard from "./OutcomesCard.tsx"; import PolicyGrants from "./PolicyGrants.tsx"; import PolicySection from "./PolicySection.tsx"; @@ -69,7 +68,6 @@ export default function RepoPanel({ repo, row, live, store, onClose }: RepoPanel {manualWebhook ? : null}
- {stats ? : null} diff --git a/apps/web/src/components/settings/CodeReviewSection.tsx b/apps/web/src/components/settings/CodeReviewSection.tsx index 21c5b7cde..cae682d9d 100644 --- a/apps/web/src/components/settings/CodeReviewSection.tsx +++ b/apps/web/src/components/settings/CodeReviewSection.tsx @@ -76,8 +76,8 @@ export function CodeReviewSection({ snapshot }: { snapshot: PortalSnapshot }) { return (
-

Code review

-

Reads each new commit of a pull request for bugs, in the repositories where it is switched on.

+

For every repository

+

The provider and the place reviews run are set once. Reads each new commit of a pull request for bugs, in the repositories where it is switched on.

{snapshot.denied.credentials && } diff --git a/apps/web/src/lib/hub-api.ts b/apps/web/src/lib/hub-api.ts index 8a58b4c39..e87d447d8 100644 --- a/apps/web/src/lib/hub-api.ts +++ b/apps/web/src/lib/hub-api.ts @@ -5,7 +5,7 @@ import { import { asRecord, asRepo, compare } from "@corbits/common"; import { GITHUB_HOOK_HEADERS, xHubHookMetadata } from "@corbits/common/hook-metadata"; import type { ApprovalResponse, CredentialResponse, GrantResponse, PrincipalResponse, RoleResponse } from "@intx/types"; -import { AuditLog, BacklogStarted, CodeReviewPlace, PolicyExported, TRIAGE_LISTENERS, PolicyFileRead, PolicyRead, PolicyUpgrades, readCheckPack, repoPolicy, repoSourceOf, checkPackName, CODE_REVIEW_PROVIDERS, currentRows, parseCheckOutput, ACTION_KINDS, RETIRED_CHECK_IDS, DeployedVersions, InviteRoles, InviteSent, SessionList, TriageStats, TriageVerdicts, type ActionKind, type ArtifactRef, type AuthorKind, type CheckOutput, type CheckPack, type MergeRisk, type PolicyFileStatus, type RepoCheckFlags, type RepoPolicy, type RepoSource, type VerdictDetail, type VerdictWord } from "@corbits/triage-contracts"; +import { AuditLog, BacklogStarted, CodeReviewPlace, PolicyExported, PolicyPatchResponse, TRIAGE_LISTENERS, PolicyFileRead, PolicyRead, PolicyUpgrades, readCheckPack, repoPolicy, repoSourceOf, checkPackName, CODE_REVIEW_PROVIDERS, currentRows, parseCheckOutput, ACTION_KINDS, RETIRED_CHECK_IDS, DeployedVersions, InviteRoles, InviteSent, SessionList, TriageStats, TriageVerdicts, type ActionKind, type ArtifactRef, type AuthorKind, type CheckOutput, type CheckPack, type MergeRisk, type PolicyFileStatus, type PolicyPatchChange, type RepoCheckFlags, type RepoPolicy, type RepoSource, type VerdictDetail, type VerdictWord } from "@corbits/triage-contracts"; import { createGrantBody, type CreateGrantInput } from "./grant-actions.ts"; const WORKSPACE_SLUG = @@ -845,6 +845,21 @@ export async function exportPolicyFile(transport: Transport, tenantId: string, r return PolicyExported.assert(await transport.fetch("POST", `/api/integrations/github-triage/${enc(requireTenantId(tenantId))}/policy/export`, { repo: clean })); } +/** One batch against the policy file. `preview` returns the file before and after and writes nothing. */ +export async function patchPolicyFile( + transport: Transport, + tenantId: string, + body: { repo: string; source?: RepoSource; changes: readonly PolicyPatchChange[]; preview?: boolean }, +): Promise { + const clean = validateRepo(body.repo); + return PolicyPatchResponse.assert(await transport.fetch("POST", `/api/integrations/github-triage/${enc(requireTenantId(tenantId))}/policy/patch`, { + repo: clean, + ...(body.source !== undefined && { source: body.source }), + changes: body.changes, + ...(body.preview === true && { preview: true }), + })); +} + export async function loadPolicyUpgrades(transport: Transport, tenantId: string): Promise { return PolicyUpgrades.assert(await transport.fetch("GET", `/api/integrations/github-triage/${enc(requireTenantId(tenantId))}/policy/upgrades`)).repos; } diff --git a/apps/web/src/lib/policy-edits.ts b/apps/web/src/lib/policy-edits.ts new file mode 100644 index 000000000..b2063df3f --- /dev/null +++ b/apps/web/src/lib/policy-edits.ts @@ -0,0 +1,209 @@ +// Edits stay in the page until one patch opens a pull request. The policy-file read names the file and not its text, so the +// text comes from a preview, which writes nothing. +import { CATALOG_IDS, CUSTOM_CHECK_CAP, QUALITY_CHECK_IDS, catalogSentence, checkPolicyFile, customSentence, fileRuleId, isCatalogId, isModelCheck, isSizeRule, readCheckPack, type CatalogId, type CheckPack, type CustomCheck, type PolicyPatchChange } from "@corbits/triage-contracts"; + +export type RuleLane = "checks" | "judging"; + +export type RuleRow = { id: string; lane: RuleLane; sentence: string; on: boolean; needsModel: boolean }; + +const ON_FAILS = ["fail", "ask", "label"] as const; +export type FailWord = (typeof ON_FAILS)[number]; + +export const FAIL_SENTENCES: Record = { + fail: "On fail, the pull request fails.", + ask: "On fail, a maintainer is asked.", + label: "On fail, it is labelled and the verdict stays.", +}; + +const CUSTOM_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export type FilePolicy = { pack: CheckPack; codeReviewOn: boolean } | { problem: string }; + +/** The file's rules and whether code review is on, or the first reason it cannot be changed. */ +export function policyFromText(text: string, repo: string): FilePolicy { + const checked = checkPolicyFile(text, repo); + if (!checked.ok) return { problem: checked.problems[0]?.message ?? "This policy can't be changed until the file is fixed." }; + try { + return { pack: readCheckPack(checked.policy, repo), codeReviewOn: checked.workflow["code-review"]?.on === true }; + } catch (cause) { + return { problem: cause instanceof Error ? cause.message : "This policy can't be changed until the file is fixed." }; + } +} + +function present(pack: CheckPack, id: string): boolean { + if (isCatalogId(id)) return pack.checks[id] !== undefined; + return pack.custom.some((row) => row.id === id); +} + +function baselineOn(pack: CheckPack, id: string): boolean { + if (isCatalogId(id)) { + const row = pack.checks[id]; + return row !== undefined && row.enabled !== false; + } + const row = pack.custom.find((item) => item.id === id); + return row !== undefined && row.enabled !== false; +} + +function editsRule(change: PolicyPatchChange, id: string): boolean { + if (change.kind === "rule" || change.kind === "remove-rule" || change.kind === "rule-options") return change.id === id; + return change.kind === "add-rule" && fileRuleId(change.rule) === id; +} + +/** A size rule needs `max` and forbidden paths need `paths`, and only when the file does not already list the rule. */ +export function absentValue(pack: CheckPack, id: string): "max" | "paths" | undefined { + if (present(pack, id)) return undefined; + if (isSizeRule(id)) return "max"; + return id === "no-forbidden-paths" ? "paths" : undefined; +} + +export function ruleOn(pack: CheckPack, id: string, changes: readonly PolicyPatchChange[]): boolean { + const added = changes.some((change) => change.kind === "add-rule" && fileRuleId(change.rule) === id); + if (added) return true; + const last = changes.findLast((change) => change.kind === "rule" && change.id === id); + return last?.kind === "rule" ? last.enabled : baselineOn(pack, id); +} + +export function codeReviewOn(baseline: boolean, changes: readonly PolicyPatchChange[]): boolean { + const last = changes.findLast((change) => change.kind === "code-review"); + return last?.kind === "code-review" ? last.on : baseline; +} + +/** A switch that returns to the file drops its change. A new size or path rule is not enabled until its value is set beside it. */ +export function toggleRule(pack: CheckPack, changes: readonly PolicyPatchChange[], id: string, next: boolean): PolicyPatchChange[] { + const rest = changes.filter((change) => !editsRule(change, id)); + const added = changes.some((change) => change.kind === "add-rule" && fileRuleId(change.rule) === id); + if (added) return next ? [...changes] : rest; + if (next === baselineOn(pack, id)) return rest; + if (!next && !present(pack, id)) return rest; + if (next && absentValue(pack, id) !== undefined) return rest; + return [...rest, { kind: "rule", id, enabled: next }]; +} + +/** Enable a rule the file does not list, then set the limit or paths the person entered. The enable comes first so the option applies to it. */ +export function withRequiredValue(changes: readonly PolicyPatchChange[], id: string, options: { max: number } | { paths: string[] }): PolicyPatchChange[] { + const rest = changes.filter((change) => !editsRule(change, id)); + return [...rest, { kind: "rule", id, enabled: true }, { kind: "rule-options", id, options }]; +} + +/** False while a new size or path rule is enabled without the value the route requires. */ +export function batchReady(pack: CheckPack, changes: readonly PolicyPatchChange[]): boolean { + return changes.every(function ready(change) { + if (change.kind !== "rule" || !change.enabled) return true; + const field = absentValue(pack, change.id); + if (field === undefined) return true; + const options = changes.find((item) => item.kind === "rule-options" && item.id === change.id); + if (options?.kind !== "rule-options") return false; + if (field === "max") return typeof options.options.max === "number" && Number.isInteger(options.options.max) && options.options.max >= 1; + const paths = options.options.paths; + return Array.isArray(paths) && paths.length > 0 && paths.every((path) => typeof path === "string" && path.trim().length > 0); + }); +} + +export function toggleCodeReview(baseline: boolean, changes: readonly PolicyPatchChange[], next: boolean): PolicyPatchChange[] { + const rest = changes.filter((change) => change.kind !== "code-review"); + return next === baseline ? rest : [...rest, { kind: "code-review", on: next }]; +} + +function laneOf(id: CatalogId): RuleLane { + return (QUALITY_CHECK_IDS as readonly string[]).includes(id) ? "judging" : "checks"; +} + +function customLane(row: CustomCheck): RuleLane { + return isModelCheck(row) ? "judging" : "checks"; +} + +function customText(row: CustomCheck): string { + return row.kind === "model" && row.shape === "is-true" ? row.claim : customSentence(row); +} + +function sentenceOf(pack: CheckPack, id: string, changes: readonly PolicyPatchChange[]): string { + if (isCatalogId(id)) return catalogSentence(id, pack); + const row = pack.custom.find((item) => item.id === id); + if (row) return customText(row); + const added = changes.find((change) => change.kind === "add-rule" && fileRuleId(change.rule) === id); + return added?.kind === "add-rule" && added.rule.check === "model" ? added.rule.text : id; +} + +function onFirst(rows: RuleRow[]): RuleRow[] { + return [...rows.filter((row) => row.on), ...rows.filter((row) => !row.on)]; +} + +/** Built-in rules, custom rules and a judge rule added in this batch. On rows sit above off rows. */ +export function ruleRows(pack: CheckPack, changes: readonly PolicyPatchChange[], hasModel: boolean): Record { + const catalog = CATALOG_IDS.map(function rowOf(id): RuleRow { + const on = ruleOn(pack, id, changes); + const judging = laneOf(id) === "judging"; + return { id, lane: laneOf(id), sentence: catalogSentence(id, pack), on, needsModel: judging && !hasModel }; + }); + const custom = pack.custom.map(function rowOf(row): RuleRow { + const lane = customLane(row); + return { id: row.id, lane, sentence: customText(row), on: ruleOn(pack, row.id, changes), needsModel: lane === "judging" && !hasModel }; + }); + const added = changes.flatMap(function rowOf(change): RuleRow[] { + if (change.kind !== "add-rule" || change.rule.check !== "model") return []; + return [{ id: change.rule.id, lane: "judging", sentence: change.rule.text, on: true, needsModel: !hasModel }]; + }); + const rows = [...catalog, ...custom, ...added]; + return { checks: onFirst(rows.filter((row) => row.lane === "checks")), judging: onFirst(rows.filter((row) => row.lane === "judging")) }; +} + +export function judgingOn(rows: RuleRow[]): boolean { + return rows.some((row) => row.on); +} + +export function changeSentences(pack: CheckPack, changes: readonly PolicyPatchChange[]): string[] { + return changes.map(function sentence(change) { + switch (change.kind) { + case "rule": + return `${change.enabled ? "Turn on" : "Turn off"} ${sentenceOf(pack, change.id, changes)}.`; + case "code-review": + return change.on ? "Turn on code review." : "Turn off code review."; + case "add-rule": + return change.rule.check === "model" ? `Add a judge rule: ${change.rule.text}` : `Add ${fileRuleId(change.rule)}.`; + case "remove-rule": + return `Remove ${sentenceOf(pack, change.id, changes)}.`; + case "rule-options": + if (typeof change.options.max === "number") return `Set the limit to ${change.options.max.toLocaleString()}.`; + if (Array.isArray(change.options.paths)) return `Set the paths to ${change.options.paths.join(", ")}.`; + return `Change ${sentenceOf(pack, change.id, changes)}.`; + } + }); +} + +export function touchedIds(changes: readonly PolicyPatchChange[]): string[] { + return changes.map(function idOf(change) { + switch (change.kind) { + case "rule": + case "remove-rule": + case "rule-options": + return change.id; + case "add-rule": + return fileRuleId(change.rule); + case "code-review": + return "code-review"; + } + }); +} + +function takenIds(pack: CheckPack, changes: readonly PolicyPatchChange[]): string[] { + return [...CATALOG_IDS, ...pack.custom.map((row) => row.id), ...changes.flatMap((change) => (change.kind === "add-rule" ? [fileRuleId(change.rule)] : []))]; +} + +/** A model rule the file can hold: the claim is the check, it passes when the claim is true, and `onFail` is the fail answer. */ +export function judgeRuleChange(pack: CheckPack, changes: readonly PolicyPatchChange[], claim: string, onFail: FailWord): PolicyPatchChange | undefined { + const text = claim.trim(); + if (!text) return undefined; + if (pack.custom.length + changes.filter((change) => change.kind === "add-rule").length >= CUSTOM_CHECK_CAP) return undefined; + const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48).replace(/-+$/g, ""); + let id = CUSTOM_ID.test(slug) ? slug : "judge-rule"; + const taken = takenIds(pack, changes); + let n = 2; + while (taken.includes(id)) { + id = `${slug || "judge-rule"}-${n}`; + n += 1; + } + return { + kind: "add-rule", + rule: { id, check: "model", text, reason: text, evidence: ["maintainer-stated"], ...(onFail !== "fail" && { onFail }) }, + }; +} diff --git a/apps/web/src/lib/policy-file.ts b/apps/web/src/lib/policy-file.ts index d582d01eb..f3a0a851e 100644 --- a/apps/web/src/lib/policy-file.ts +++ b/apps/web/src/lib/policy-file.ts @@ -11,8 +11,8 @@ function policyFileQuery(tenantId: string | undefined, repo: string) { return queryOptions({ queryKey: ["policy-file", tenantId, repo], queryFn, staleTime: 60_000, refetchOnMount: "always" }); } -/** The repository's policy file. */ -export function usePolicyFile(repo: string) { +/** The repository's policy file. While `poll` is set, an open pull request is read again until it merges. */ +export function usePolicyFile(repo: string, poll = false) { const { snapshot } = usePortal(); - return useQuery(policyFileQuery(snapshot?.workspace.tenantId, repo)); + return useQuery({ ...policyFileQuery(snapshot?.workspace.tenantId, repo), refetchInterval: poll ? 30_000 : false }); } diff --git a/apps/web/src/pages/CodeReview.tsx b/apps/web/src/pages/CodeReview.tsx new file mode 100644 index 000000000..8422fd4f3 --- /dev/null +++ b/apps/web/src/pages/CodeReview.tsx @@ -0,0 +1,206 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@corbits/react-ui"; +import { errorText } from "@corbits/common"; +import type { PolicyPatchChange, PolicyPatched } from "@corbits/triage-contracts"; +import { CodeReviewSection } from "../components/settings/CodeReviewSection.tsx"; +import { DecisionModelForm } from "../components/DecisionModelForm.tsx"; +import { KeyRow } from "../components/settings/KeyRow.tsx"; +import { PolicyEditor } from "../components/code-review/PolicyEditor.tsx"; +import { RepoSwitcher, repoKey } from "../components/code-review/RepoSwitcher.tsx"; +import { ExternalIcon } from "../components/inbox-icons.tsx"; +import { HOST_NAME } from "../components/repo-panel/PolicyMove.tsx"; +import { modelSecrets } from "../lib/credential-actions.ts"; +import { exportPolicyFile, isActiveCredential, patchPolicyFile, type RepoRecord } from "../lib/hub-api.ts"; +import { createHubTransport } from "../lib/hub-transport.ts"; +import { usePolicyFile } from "../lib/policy-file.ts"; +import { policyFromText, touchedIds } from "../lib/policy-edits.ts"; +import { usePolicyUpgrade, usePolicyUpgradeActions, POLICY_UPGRADES_QUERY_KEY } from "../lib/policy-upgrades.ts"; +import { usePortal } from "../lib/portal.tsx"; + +type OpenPull = { number: number; url: string; state: "open"; headSha?: string; shaAtOpen?: string; touched: string[] }; + +function JudgeModel() { + const { snapshot, removeDecisionModel } = usePortal(); + if (!snapshot) return null; + const saved = snapshot.decisionModel; + const words = saved ? <>Judge pull requests with {saved.model} : "Judge pull requests with a decision model"; + return ( +
+
+ Judging + The model is set once for every repository. +
+
+ } + /> +
+
+ ); +} + +function OpenRequest({ pull, host, busy, onMerge }: { pull: OpenPull; host: string; busy: boolean; onMerge: () => void }) { + return ( +
+
+ Pull request #{pull.number} open, waiting for review + {pull.headSha ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} + +export default function CodeReview() { + const { snapshot, readOnly } = usePortal(); + const repos = snapshot?.repos ?? []; + const [picked, setPicked] = useState(null); + const selected = repos.find((repo) => repoKey(repo) === picked) ?? repos[0]; + const [opened, setOpened] = useState<{ key: string; pull: OpenPull } | null>(null); + const [applyError, setApplyError] = useState(""); + const queryClient = useQueryClient(); + const tenantId = snapshot?.workspace.tenantId; + const file = usePolicyFile(selected?.name ?? "", opened?.key === (selected ? repoKey(selected) : "")); + const upgrade = usePolicyUpgrade(selected?.name ?? ""); + const actions = usePolicyUpgradeActions(selected?.name ?? "", function settled() { + setOpened(null); + }); + + if (opened && selected && repoKey(selected) === opened.key && file.data?.sha && opened.pull.shaAtOpen && file.data.sha !== opened.pull.shaAtOpen) { + setOpened(null); + } + + const listedPull = upgrade?.pull; + const own = opened && selected && repoKey(selected) === opened.key ? opened.pull : null; + const pull: OpenPull | null = own ?? (listedPull ? { number: listedPull.number, url: listedPull.url, state: "open", headSha: listedPull.headSha, touched: [] } : null); + + const text = useQuery({ + queryKey: ["policy-file-text", tenantId, selected?.name, selected?.source, file.data?.sha ?? null], + enabled: tenantId !== undefined && selected !== undefined && file.data !== undefined && file.data !== null && file.data.problem === undefined, + retry: false, + queryFn: async function readFileText() { + if (tenantId === undefined || selected === undefined) throw new Error("The workspace is not known yet."); + // The policy-file read does not include the text. Preview writes nothing and returns it. + const preview = await patchPolicyFile(createHubTransport(), tenantId, { repo: selected.name, source: selected.source, changes: [{ kind: "code-review", on: true }], preview: true }); + if (!("before" in preview)) throw new Error("The preview did not return the file."); + return preview.before; + }, + }); + + const parsed = text.data && selected ? policyFromText(text.data, selected.name) : undefined; + const applying = useMutation({ + mutationFn: async function apply(changes: readonly PolicyPatchChange[]): Promise { + if (tenantId === undefined || selected === undefined) throw new Error("The workspace is not known yet."); + const result = await patchPolicyFile(createHubTransport(), tenantId, { repo: selected.name, source: selected.source, changes }); + if (!("number" in result)) throw new Error("The pull request was not opened."); + return result; + }, + onSuccess: async function openedPull(result, changes) { + if (!selected) return; + setApplyError(""); + setOpened({ key: repoKey(selected), pull: { number: result.number, url: result.url, state: result.state, headSha: result.headSha, shaAtOpen: file.data?.sha, touched: touchedIds(changes) } }); + await Promise.all([POLICY_UPGRADES_QUERY_KEY, "policy-file", "policy-file-text"].map((key) => queryClient.invalidateQueries({ queryKey: [key, tenantId] }))); + }, + }); + + const start = useMutation({ + mutationFn: function startFile() { + if (tenantId === undefined || selected === undefined) throw new Error("The workspace is not known yet."); + return exportPolicyFile(createHubTransport(), tenantId, selected.name); + }, + onSuccess: async function started(result) { + if (!selected) return; + setOpened({ key: repoKey(selected), pull: { number: result.number, url: result.url, state: "open", headSha: result.headSha, shaAtOpen: file.data?.sha, touched: [] } }); + await queryClient.invalidateQueries({ queryKey: [POLICY_UPGRADES_QUERY_KEY, tenantId] }); + }, + }); + + function select(repo: RepoRecord) { + setPicked(repoKey(repo)); + setApplyError(""); + } + + async function apply(changes: readonly PolicyPatchChange[]) { + try { + await applying.mutateAsync(changes); + } catch (cause) { + setApplyError(errorText(cause)); + throw cause; + } + } + + async function preview(changes: readonly PolicyPatchChange[]) { + if (tenantId === undefined || selected === undefined) throw new Error("The workspace is not known yet."); + const result = await patchPolicyFile(createHubTransport(), tenantId, { repo: selected.name, source: selected.source, changes, preview: true }); + if (!("before" in result)) throw new Error("The preview did not return the file."); + return { before: result.before, after: result.after }; + } + + function merge() { + if (!pull?.headSha || !selected) return; + actions.merge.mutate({ number: pull.number, headSha: pull.headSha }); + } + + function body() { + if (!selected) return

No repository is connected yet.

; + if (file.isLoading || (file.data && text.isLoading)) return

Loading the policy.

; + if (file.isError) return

{errorText(file.error)}

; + if (file.data === null) { + if (pull) return null; + return ( +

This repository has no policy yet.

+ ); + } + if (file.data?.problem || (parsed && "problem" in parsed)) { + const reason = file.data?.problem ?? (parsed && "problem" in parsed ? parsed.problem : ""); + return

This policy can't be changed until the file is fixed. {reason}

; + } + if (text.isError) return

{errorText(text.error)}

; + if (!parsed || "problem" in parsed) return null; + return ( + + ); + } + + const host = selected ? HOST_NAME[selected.source] : "GitHub"; + + return ( +
+

Code review

+

Rules, judging and code review for one repository, changed by a pull request. The model and where reviews run are set once for every repository.

+ + {pull && selected ? : null} + {actions.merge.error ?

{errorText(actions.merge.error)}

: null} + {start.error ?

{errorText(start.error)}

: null} + {body()} + + {snapshot ? : null} +
+ ); +} diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index ceb22ccff..31f508bc3 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -1,18 +1,16 @@ import { useEffect, useRef, useState, type RefObject } from "react"; -import { Link, useParams } from "react-router-dom"; +import { Link, Navigate, useParams } from "react-router-dom"; import { connectedSources, isPerson, type HubPrincipal, type PortalSnapshot } from "../lib/hub-api.ts"; import { counted } from "../lib/plural.ts"; import { usePortal } from "../lib/portal.tsx"; import { useDeployedWorkflows, usePrincipals, type TenantSection } from "../lib/tenant-entities.ts"; import { InvitePeople } from "../components/InvitePeople.tsx"; import { AccountSection } from "../components/settings/AccountSection.tsx"; -import { CodeReviewSection } from "../components/settings/CodeReviewSection.tsx"; import { SupportVersions } from "../components/settings/SupportVersions.tsx"; import { TriageSection } from "../components/settings/TriageSection.tsx"; const SECTIONS = [ { id: "triage", label: "Triage" }, - { id: "code-review", label: "Code review" }, { id: "people", label: "People" }, { id: "account", label: "Your account" }, ] as const; @@ -74,6 +72,7 @@ export default function Settings() { } if (!snapshot) return null; + if (tab === "code-review") return ; return (

Workspace settings

@@ -82,7 +81,6 @@ export default function Settings() { {SECTIONS.map((section) => jumpTo(section.id)}>{section.label})} - {!deployed.denied && !deployed.loading && !deployed.unavailable && } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 60ba5ccbd..a31cd904f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -163,9 +163,9 @@ GitHub ──webhook──► Hub ──spawn──► Sidecar ──► listene stock's plugin registry selects for it under the workspace's placement policies, or null, and lists every place the hub's provisioner packages describe (`DOCKER_PLACE`, `DOCKER_REMOTE_PLACE`, `E2B_PLACE`, `RAILWAY_PLACE`) with the state the hub - found at boot, for Settings → Code review. The hub's own machine is never + found at boot, for the Code Review page. The hub's own machine is never one. While it is null **Review again** refuses (`no_isolated_place`), - nothing is mailed, and the repository page links Settings → Code review. + nothing is mailed, and the Code Review page says a place is needed. - **Portal** (`apps/web`): owns setup. It writes the tenant, credentials and inference offerings over stock hub routes, and **Upgrade to new version** publishes the workflow sources and deploys the listeners. The hub seeds nothing for it. It is a wrapper over the @@ -492,9 +492,11 @@ operation has one implementation. A repository's policy file turns code review on with its `code-review` block, and **Review again** asks for one whatever the file says; both need a code -review provider's key. A -workspace runs one provider, the one whose key it stores in Settings → Code -review +review provider's key. The Code Review page reads that file (the policy-file +route names it; a preview of the patch route, which writes nothing, returns +its text) and opens one pull request for a batch of rule and code-review +changes. A +workspace runs one provider, the one whose key it stores on that page (`CODE_REVIEW_PROVIDERS` in `triage-contracts`; only Anthropic today). A pull request's code review runs in its manager: the hub attaches the review request to a pull request mail whose `codeReview` is on, which the policy diff --git a/docs/Corbits Triage User Manual/Corbits Triage User Manual.md b/docs/Corbits Triage User Manual/Corbits Triage User Manual.md index 98cebbd6c..93e505c1b 100644 --- a/docs/Corbits Triage User Manual/Corbits Triage User Manual.md +++ b/docs/Corbits Triage User Manual/Corbits Triage User Manual.md @@ -8,9 +8,10 @@ Oct 16, 2026 · @Brian J. Fox 2. [Getting started](#getting-started) 3. [Setting up a repository: rules, verdicts and actions](#setting-up-a-repository-rules-verdicts-and-actions) 4. [Day to day: the Inbox](#day-to-day-the-inbox) -5. [Settings: Triage, code review and people](#settings-triage-code-review-and-people) -6. [The audit log](#the-audit-log) -7. [Glossary and common questions](#glossary-and-common-questions) +5. [Code review](#code-review-page) +6. [Settings: Triage and people](#settings-triage-and-people) +7. [The audit log](#the-audit-log) +8. [Glossary and common questions](#glossary-and-common-questions) ## What Corbits Triage is @@ -97,7 +98,7 @@ Once the steps are done or skipped, the portal takes you to Repositories, where ## Setting up a repository: rules, verdicts and actions -Every repository has its own page under **Repositories** in the menu under your name. A repository's rules, and what Triage does with each verdict, live in a file in the repository called `.corbits-triage.yml`. The page reads that file back to you. From top to bottom it holds the triage switch, **Actions Triage takes on its own**, the **Policy**, **Code review**, **Outcomes** and **Remove repository**. Switches are saved as you change them; the word beside the repository's name says **Saving…** while a change is on its way, **Saved** once it is stored, and **Not saved** if the save failed. +Every repository has its own page under **Repositories** in the menu under your name. A repository's rules, and what Triage does with each verdict, live in a file in the repository called `.corbits-triage.yml` (the policy file). The page reads that file back to you. From top to bottom it holds the triage switch, **Actions Triage takes on its own**, the **Policy**, **Outcomes** and **Remove repository**. You change the rules, judging and code review on the **Code review** page. Switches on the repository page are saved as you change them; the word beside the repository's name says **Saving…** while a change is on its way, **Saved** once it is stored, and **Not saved** if the save failed. ![The repository page for acme/gadgets beside the Repositories list: the triage switch on, then Policy with Change on GitHub and its rules read back as sentences, from Flag pull requests over 25 files changed to Never accept changes to vendor/**, node_modules/**, then Pass: Comment with the verdict, Label “Triage: Ready”, Approve, for maintainers; Fail: Comment with the verdict, Label “Triage: Not Ready”; Unsure: Comment with the verdict, Label “Triage: Needs Human Review”, Request review from code owners, and Code review below](images/06.png) @@ -383,19 +384,19 @@ On Bitbucket Data Center labels have no effect; see [Bitbucket Data Center repos ### Code review -Code review reads each new commit of a pull request for bugs, and skips drafts. It runs with triage, so only in a repository where triage is on, and only when the repository's [policy file](#writing-the-file) turns code review on. **Review again** reviews the latest commit once more, whatever the policy file says. +Code review reads each new commit of a pull request for bugs, and skips drafts. It runs with triage, so only in a repository where triage is on, and only when the repository's [policy file](#writing-the-file) turns code review on. You turn it on from the [Code review](#code-review-page) page, which opens a pull request for the change. **Review again** reviews the latest commit once more, whatever the policy file says. -It needs the workspace's Anthropic key, saved under [Settings → Code review](#code-review-2). +It needs the workspace's Anthropic key, saved on that page. ### Where code review runs -Code review checks out the pull request's code, so each review runs in a place of its own, apart from Triage and from other reviews: a Docker container on the server that runs Triage, or on a separate machine such as a VPS ([Docker on another machine](../provisioners/docker-remote.md) says how your server administrator sets that up), or in an [E2B](../provisioners/e2b.md) or [Railway](../provisioners/railway-sandboxes.md) sandbox in the cloud. [Settings → Code review](#code-review-2) says where it runs. +Code review checks out the pull request's code, so each review runs in a place of its own, apart from Triage and from other reviews: a Docker container on the server that runs Triage, or on a separate machine such as a VPS ([Docker on another machine](../provisioners/docker-remote.md) says how your server administrator sets that up), or in an [E2B](../provisioners/e2b.md) or [Railway](../provisioners/railway-sandboxes.md) sandbox in the cloud. The [Code review](#code-review-page) page says where it runs. -A server with no isolated place set up for Triage has nowhere to run it. Each repository's page then says **Code review needs an isolated place to run**, with **Set one up**, which opens Settings → Code review, and **Review again** is hidden in the Inbox. Nothing is reviewed until a place is set up. +A server with no isolated place set up for Triage has nowhere to run it. The Code review page then says **Code review needs an isolated place to run**, with **Set one up**, and **Review again** is hidden in the Inbox. Nothing is reviewed until a place is set up. ![The Code review section on a server with no isolated place: Code review needs an isolated place to run, with Set one up](images/38.png) -If [Settings → Code review](#code-review-2) says **Could not read where code review runs**, repository pages leave out their Code review section and **Review again** is hidden in the Inbox. +If the Code review page says **Could not read where code review runs**, **Review again** is hidden in the Inbox. ### Removing a repository @@ -417,6 +418,7 @@ The dark strip on the left is the same on every page: - **Workspace name** at the top. If you belong to more than one workspace, click it to switch. - **Pulse**: a summary of what changed since your last visit. +- **Code review**: the rules, judging and bug finding for one repository. You pick the repository at the top. Each rule is a switch. Off rules sit below the ones that are on. A change waits in a tray until you open one pull request, which stays listed until it merges. A repository with no policy file offers one button to start one. The model used for judging, and where bug finding runs, are set once for every repository on the same page. - **Inbox**, with three views. **Needs you** shows only pull requests waiting on a person, in repositories with triage on. A pull request with an approval or a held action waiting stays there even after triage is turned off. **Ready** shows the pull requests you can merge now. **All open** shows every open pull request in every repository with triage on, and any with an approval waiting. - **Your name** at the bottom. Click it for Repositories, Settings and Sign out. @@ -517,9 +519,25 @@ Authors do not use the portal. Depending on the repository's actions, they see a ![The Pulse page: four counters for new, resolved, now waiting on you and acted on by you since the last visit, a Stuck section, and a table per repository with triaged, needed you, acted on, and median time to merge or close and to first response beside the before-Triage median](images/16.png) -## Settings: Triage, code review and people +## Code review {#code-review-page} -**Settings** is reached from the menu under your name at the bottom of the left rail. It is one page with four sections: Triage, Code review, People and Your account. The links at the top jump to each section. At the very bottom, **Versions for support** opens the version of Triage deployed for each connected host, marked **(not running)** when it is not up, with a **Copy** button; support will ask for it when you report a problem. Most teams visit it once during setup and then only to invite people. +**Code review** is in the left rail. A policy file is the repository's `.corbits-triage.yml` on its default branch: it decides which rules run, including judging and bug finding (code review). + +Pick a repository at the top. You can search, use the arrow keys, and the ones you opened lately stay near the top. Under it, a line says how many rules are on, and whether judging and code review are on. + +Rules are sentences with a switch, in three groups: **Checks**, **Judging** and **Code review**. Turning a switch on shows what Triage will do. If judging needs a model, the row says **Needs a model** and **Add a model** is the one thing that fixes it. If code review has nowhere to run, the row says **Code review needs an isolated place to run** and **Set one up** is the one thing that fixes it. + +Changes sit in a tray: how many there are, each as a sentence, then the policy file before and after. **Open a pull request** applies them together. The page then says the pull request is open and waiting for review. A dot stays on each changed row until that pull request merges. **Merge** merges it when the host allows it from here. + +**New judge rule** asks three questions, in order: what Triage should check, when it passes, and what happens on fail. The sentence on the form is the rule that lands in the file. It passes when that check is true. + +A repository with no policy file says **This repository has no policy yet** and offers **Start from** that repository. + +**Judging** and **For every repository**, at the bottom, are set once for the whole workspace: the decision model, the Anthropic key bug finding uses, and where those reviews run. + +## Settings: Triage and people + +**Settings** is reached from the menu under your name at the bottom of the left rail. It is one page with three sections: Triage, People and Your account. The links at the top jump to each section. The provider and the place code review runs are on the [Code review](#code-review-page) page. The decision model stays under Triage, and that page sets it too. At the very bottom, **Versions for support** opens the version of Triage deployed for each connected host, marked **(not running)** when it is not up, with a **Copy** button; support will ask for it when you report a problem. Most teams visit it once during setup and then only to invite people. ### Triage @@ -588,11 +606,11 @@ Every secret Triage holds can be replaced from Settings without disconnecting an **Bitbucket Data Center webhook secret.** Press **Rotate secret** on the webhook secret row. Triage saves a new secret and updates every webhook it created to use it. Repositories whose webhook was set up by hand, or whose webhook Triage could not update, are named afterwards: each one's repository page shows the new secret, so update its webhook in Bitbucket from there. Pull requests that change in the meantime are picked up automatically. -**Model keys.** Press **Rotate** on the [decision model](#decision-model) or on the Anthropic key under [Code review](#code-review-2), and paste the new key. The key already stored is not shown. +**Model keys.** Press **Rotate** on the [decision model](#decision-model) or on the Anthropic key on the [Code review](#code-review-page) page, and paste the new key. The key already stored is not shown. ### Code review -![The Code review section: Review code with Anthropic, No key, with the Anthropic key field and Save key, then Where it runs: Docker on this machine, In use; Docker on another machine (TLS), Not set up, with its .env lines, Copy and Setup guide](images/43.png) +These controls are on the [Code review](#code-review-page) page, once for every repository. [Code review](#code-review) runs with the provider whose key is stored here; today that is Anthropic. **Review code with Anthropic** says **Key stored** or **No key**. With no key, the Anthropic key field is open: paste the key and press **Save key**. When a key is stored, that field is hidden and the key itself is not shown. **Rotate** opens a blank field for a new key; press **Save key** to replace the stored one. **Remove** deletes it after you confirm. As for the decision model, when Triage cannot read the saved keys, the row says so and shows no field. @@ -602,7 +620,7 @@ Each place says **In use**, **Ready**, **Not set up** or **Failing**; admins als ![Where it runs on a server with no place set up: Docker on this machine and Docker on another machine (TLS), both Not set up, each with How to set it up](images/44.png) -If the server cannot say where code review runs, **Where it runs** is replaced by **Could not read where code review runs** with the reason, and repository pages leave out their Code review section. The rest of the portal works as usual; reload the page to try again. +If the server cannot say where code review runs, **Where it runs** is replaced by **Could not read where code review runs** with the reason. The rest of the portal works as usual; reload the page to try again. ### People @@ -687,7 +705,7 @@ Type in the search box to narrow the list by actor, action or pull request. The **Why did Triage not call the decision model for a failing pull request?** When a rule fails, the answer is already Fail, so the model is not asked the verdict question. A repository with no Judgement rules never asks it at all. This saves money and keeps verdicts predictable. -**Code review shows nothing for a pull request.** Check that the repository's policy file turns code review on, and that an Anthropic key is stored under Settings → Code review. It runs only after triage, for the groups in `for` and on the verdicts and paths in `when`. By default it reviews only once the rules in your file that fail a pull request and need no model have passed. CI counts only when `ci-green` is on. Triage's built-in checks, such as a draft or a requested change, don't hold the review back. A pull request that fails one gets its Fail with those reasons and no review, and one whose CI is still running is reviewed once CI passes. Write `when: { rules: any }` to review whatever those rules say. Drafts are not reviewed until they are marked ready. A review takes a minute or two; the section says **Reviewing** while it runs. **Review again** starts a new one. If the Code review section says **Code review needs an isolated place to run**, whoever runs your Triage server sets one up first. +**Code review shows nothing for a pull request.** Check that the repository's policy file turns code review on, and that an Anthropic key is stored on the Code review page. It runs only after triage, for the groups in `for` and on the verdicts and paths in `when`. By default it reviews only once the rules in your file that fail a pull request and need no model have passed. CI counts only when `ci-green` is on. Triage's built-in checks, such as a draft or a requested change, don't hold the review back. A pull request that fails one gets its Fail with those reasons and no review, and one whose CI is still running is reviewed once CI passes. Write `when: { rules: any }` to review whatever those rules say. Drafts are not reviewed until they are marked ready. A review takes a minute or two; the section says **Reviewing** while it runs. **Review again** starts a new one. If the Code review page says **Code review needs an isolated place to run**, whoever runs your Triage server sets one up first. **The author pushed a fix. Do I need to do anything?** No. Every new commit gets its own verdict, and Triage updates its comment. If you had requested changes through Triage and every failed rule is now fixed, Triage withdraws the request for you. If a reviewer requested changes, the pull request moves to **Needs you** for their re-review.