Skip to content
Merged
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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ Or by hand:
```

4. **Turn on triage** for the repository under Repositories. Once a decision
model is saved, its open pull requests appear in the Inbox with a verdict,
model is saved, its open pull requests appear on Triage with a verdict,
a score and the reasons. Triage posts its comment and verdict
label on GitHub; every other action waits for a click in the Inbox until
label on GitHub; every other action waits for a click on Triage until
you let Triage take it on its own.

## Other setup paths
Expand Down
197 changes: 197 additions & 0 deletions apps/web/src/App.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { afterEach, expect, mock, test } from "bun:test";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import App from "./App.tsx";
import type { PortalSnapshot, PrItem } from "./lib/hub-api.ts";
import * as checkPacks from "./lib/check-packs.ts";
import * as openPulls from "./lib/open-pulls.ts";
import * as portal from "./lib/portal.tsx";

function Passthrough({ children }: { children: ReactNode }) {
return children;
}

const snapshot: PortalSnapshot = {
workspace: { tenantId: "tenant", principalId: "principal", userId: "user" },
tenantName: "Acme",
workspaces: [],
invitations: [],
repos: [{ name: "acme/widgets", connected: true, source: "github", enabled: true }],
credentials: [],
denied: { repos: false, credentials: false, models: false },
};

function item(overrides: Partial<PrItem>): PrItem {
return {
key: "acme/widgets#1",
repo: "acme/widgets",
number: 1,
title: "Fix",
author: "ada",
authorKind: null,
byTriage: false,
draft: false,
mergeable: null,
mergeBlock: null,
state: "needs-decision",
priority: "P2",
owner: null,
nextAction: null,
verdict: "unsure",
score: 0.5,
reasons: [],
evidence: [],
checks: [],
labels: [],
comment: null,
sha: null,
verdictSha: null,
degraded: null,
needsHuman: false,
closed: false,
pendingApprovalId: null,
runId: "run-1",
waitingSince: "2026-10-01T00:00:00.000Z",
updatedAt: null,
ci: null,
headSha: null,
activityAt: null,
reviewAnswered: false,
posted: false,
running: false,
waitingForCi: false,
waitingHeadSha: null,
failure: null,
actions: [],
automatic: [],
href: "/prs/acme/widgets/1",
source: "github",
url: null,
...overrides,
};
}

const open = [
{ item: item({ title: "Needs a decision" }), handled: null },
{ item: item({ key: "acme/widgets#2", number: 2, title: "Waiting on the author", state: "needs-author-update", posted: true, comment: "Please rebase" }), handled: null },
];

mock.module("./lib/session.tsx", () => ({
SessionProvider: Passthrough,
useSession() {
return { ready: true, session: { id: "user", email: "ada@acme.dev" }, error: "", async signIn() {}, async signUp() {}, async signOut() {} };
},
}));

mock.module("./lib/user-cache.tsx", () => ({ UserQueryCache: Passthrough }));

mock.module("./lib/portal.tsx", () => ({
...portal,
PortalProvider: Passthrough,
usePortal() {
return { configured: true, loading: false, connected: true, readOnly: false, snapshot, invitations: [], unverified: false, loadError: "", warn() {}, notify() {} };
},
}));

mock.module("./lib/held-inbox.tsx", () => ({
HeldInboxProvider: Passthrough,
useSignOutAfterSending() { return async function signOut() {}; },
useOpenWorkspaceAfterSending() { return async function openWorkspace() {}; },
useHeldInbox() {
return {
notices: { held: null, outcome: null },
restored: {},
hold() {},
undo() { return false; },
forgetRestored() {},
enter() { return () => {}; },
flush() { return Promise.resolve(); },
};
},
}));

mock.module("./lib/open-pulls.ts", () => ({
...openPulls,
leavesOpen() { return false; },
useOpenItems() { return open; },
useMarkedItems() { return { open, known: open }; },
useOpenPulls() { return { data: undefined, isError: false, isEnabled: false }; },
useOpenPullsUnanswered() { return false; },
useQueueLoading() { return false; },
}));
Comment on lines +113 to +121

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · dom test mock of open-pulls omits exports the app imports

Why it is wrong: the mock for ./lib/open-pulls.ts does not provide useOpenItems, which Layout.tsx imports, or usePullPageItems, which PRDetail.tsx imports. A missing named export in a Bun module mock can break linking, so the sign-in gate test may not run at all, even though the description says it was verified.

Trigger: running the apps/web dom tests, since App.dom.test.tsx imports App and therefore Layout and PRDetail.

Fix: add useOpenItems and usePullPageItems to the mock factory, or use importActual and override only what the test needs, then confirm the sign-in gate test actually executes.

`mock.module("./lib/open-pulls.ts", () => ({


mock.module("./lib/triage-verdicts.ts", () => ({
useTriageVerdicts() {
return { data: undefined, pending: false, denied: false, error: null };
},
}));

mock.module("./lib/check-packs.ts", () => ({
...checkPacks,
useCheckPacks() { return new Map(); },
useRepoPack() { return null; },
packOf() { return null; },
}));

mock.module("./lib/tenant-entities.ts", () => ({
useRoles() {
return { rows: [], loading: false, denied: false, unavailable: false };
},
useAllowed() { return false; },
}));

mock.module("./lib/policy-upgrades.ts", () => ({
usePolicyUpgrades() { return []; },
upgradeLabel() { return ""; },
}));

mock.module("./lib/media-query.ts", () => ({
useMediaQuery() { return false; },
}));

const realFetch = globalThis.fetch;
let root: Root | undefined;

afterEach(function restore() {
act(() => root?.unmount());
document.body.innerHTML = "";
globalThis.fetch = realFetch;
});

let requested: string[] = [];

function renderAt(path: string) {
requested = [];
globalThis.fetch = async function offline(input: RequestInfo | URL) {
requested.push(String(input));
return new Response("", { status: 503 });
} as unknown as typeof fetch;
(window as unknown as { happyDOM: { setURL: (url: string) => void } }).happyDOM.setURL(`https://portal.test${path}`);
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => root?.render(<App />));
}

test("signing in without a decision model lands on Triage, with the Connect prompt above the pull requests that need you", () => {
renderAt("/");

expect(document.querySelector("h1")?.textContent).toBe("Triage");
expect(document.querySelector("#connect-model")?.textContent).toBe("Connect a decision model");
expect([...document.querySelectorAll("[data-inbox-row]")].map((row) => row.getAttribute("data-inbox-row"))).toEqual(["acme/widgets#1"]);
expect([...document.querySelectorAll("button")].find((button) => button.textContent?.startsWith("Waiting"))?.textContent).toBe("Waiting1");
});

test("a pull request open on the old Ready list opens on Triage with the Ready filter", () => {
renderAt("/ready/acme/widgets/1");

expect(`${window.location.pathname}${window.location.search}`).toBe("/pr/acme/widgets/1?view=ready");
expect(document.querySelector("h1")?.textContent).toBe("Triage");
});

test("Triage reads no policy when the workspace has no Triage role to allow actions to", () => {
renderAt("/");

expect(document.querySelector("h1")?.textContent).toBe("Triage");
expect(requested.filter((url) => url.includes("/policy"))).toEqual([]);
});
42 changes: 24 additions & 18 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ import { Welcome } from "./pages/Welcome.tsx";
import Audit from "./pages/Audit.tsx";
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 Settings from "./pages/Settings.tsx";
import { legacyTriageTarget } from "./lib/inbox-view.ts";

function InboxRedirect() {
return <Navigate to="/inbox" replace />;
function LegacyListRedirect() {
const { pathname, search } = useLocation();
return <Navigate to={legacyTriageTarget(pathname, search) ?? "/"} replace />;
}

function TriageRedirect() {
return <Navigate to="/" replace />;
}

function NotConfigured() {
Expand Down Expand Up @@ -145,37 +150,38 @@ function Gate() {
if (snapshot.denied.repos || snapshot.denied.credentials || snapshot.denied.models) {
return <DeniedGate snapshot={snapshot} />;
}
const entry = path === "/" || path === "/login";
if (!connected || path === "/connect") return <Connect />;

if (path === "/" && new URLSearchParams(location.search).has("installation_id")) return <GithubReturn />;
if (entry) {
return <Navigate to={snapshot.repos.length > 0 ? "/inbox" : "/repositories"} replace />;
if (path === "/" || path === "/login") {
if (snapshot.repos.length === 0) return <Navigate to="/repositories" replace />;
if (path === "/login") return <Navigate to="/" replace />;
}

return (
<Layout>
<Routes>
<Route path="/pulse" element={<Pulse />} />
<Route path="/inbox" element={<Inbox view="needs-you" />} />
<Route path="/inbox/:owner/:repo/:number" element={<Inbox view="needs-you" />} />
<Route path="/ready" element={<Inbox view="ready" />} />
<Route path="/ready/:owner/:repo/:number" element={<Inbox view="ready" />} />
<Route path="/open" element={<Inbox view="all-open" />} />
<Route path="/open/:owner/:repo/:number" element={<Inbox view="all-open" />} />
<Route path="/pulse" element={<LegacyListRedirect />} />
<Route path="/inbox" element={<LegacyListRedirect />} />
<Route path="/inbox/:owner/:repo/:number" element={<LegacyListRedirect />} />
<Route path="/ready" element={<LegacyListRedirect />} />
<Route path="/ready/:owner/:repo/:number" element={<LegacyListRedirect />} />
<Route path="/open" element={<LegacyListRedirect />} />
<Route path="/open/:owner/:repo/:number" element={<LegacyListRedirect />} />
<Route path="/triage/pr/:owner/:repo/:number" element={<PRDetail />} />
<Route path="/triage/pr/:id" element={<PRDetail />} />
<Route path="/prs/:owner/:repo/:number" element={<PRDetail />} />
<Route path="/prs/:id" element={<PRDetail />} />
<Route path="/triage/*" element={<InboxRedirect />} />
<Route path="/queue/*" element={<InboxRedirect />} />
<Route path="/approvals" element={<InboxRedirect />} />
<Route path="/triage/*" element={<TriageRedirect />} />
<Route path="/queue/*" element={<TriageRedirect />} />
<Route path="/approvals" element={<TriageRedirect />} />
<Route path="/audit" element={<Audit />} />
<Route path="/runs/*" element={<InboxRedirect />} />
<Route path="/runs/*" element={<TriageRedirect />} />
<Route path="/repositories/:owner?/:repo?" element={<Repositories />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/:tab" element={<Settings />} />
<Route path="/" element={<InboxRedirect />} />
<Route path="/" element={<Inbox />} />
<Route path="/pr/:owner/:repo/:number" element={<Inbox />} />
<Route path="*" element={<div className="empty">Page not found. Choose a page from the navigation.</div>} />
</Routes>
</Layout>
Expand Down
Loading
Loading