Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -173,6 +174,7 @@ function Gate() {
<Route path="/audit" element={<Audit />} />
<Route path="/runs/*" element={<InboxRedirect />} />
<Route path="/repositories/:owner?/:repo?" element={<Repositories />} />
<Route path="/code-review" element={<CodeReview />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/:tab" element={<Settings />} />
<Route path="/" element={<InboxRedirect />} />
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/cards.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
14 changes: 11 additions & 3 deletions apps/web/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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";
}

Expand Down Expand Up @@ -158,10 +158,14 @@ export default function Layout({ children }: { children: ReactNode }) {
<img src="/triage-logo.svg" alt="" />
Corbits Triage
</Link>
<Link className="nav-a" to="/pulse" aria-current={room === "pulse" ? "true" : undefined}>
<Link className="nav-a" to="/pulse" aria-current={path.startsWith("/pulse") ? "true" : undefined}>
<PulseIcon />
Pulse
</Link>
<Link className="nav-a" to="/code-review" aria-current={path.startsWith("/code-review") ? "true" : undefined}>
<ReviewIcon />
Code review
</Link>
<h3>Inbox</h3>
<Link className="nav-a" to="/inbox" aria-current={needsYouActive ? "true" : undefined}>
<InboxIcon />
Expand Down Expand Up @@ -200,6 +204,10 @@ export default function Layout({ children }: { children: ReactNode }) {
<ListIcon />
All open
</NavLink>
<NavLink to="/code-review">
<ReviewIcon />
Review
</NavLink>
<NavLink to="/repositories">
<RepoIcon />
Repos
Expand Down
187 changes: 187 additions & 0 deletions apps/web/src/components/code-review/PolicyEditor.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={client}>
<div className="settings">
<PolicyEditor
repo="acme/widgets"
pack={pack}
reviewOn={false}
hasModel
hasPlace
readOnly={false}
touched={[]}
applying={false}
applyError=""
preview={async function preview() {
return { before: "rules: before", after: "rules: after" };
}}
onApply={async function apply() {}}
/>
</div>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={client}>
<div className="settings">
<PolicyEditor
repo="acme/widgets"
pack={pack}
reviewOn={false}
hasModel
hasPlace
readOnly={false}
touched={[]}
applying={false}
applyError=""
preview={async function preview(changes) {
seen.push(changes.map((change) => change));
return { before: "before", after: "after" };
}}
onApply={async function apply() {}}
/>
</div>
</QueryClientProvider>,
);
});

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(
<QueryClientProvider client={client}>
<div className="settings">
<PolicyEditor
repo="acme/widgets"
pack={pack}
reviewOn={false}
hasModel
hasPlace
readOnly={false}
touched={[]}
applying={false}
applyError=""
preview={async function preview(changes) {
seen.push(changes.map((change) => change));
return { before: "before", after: "after" };
}}
onApply={async function apply() {}}
/>
</div>
</QueryClientProvider>,
);
});
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 }]]);
});
Loading
Loading