From 36840525fa1f3d323b88fbf39ea5d84e48a9ed78 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 26 Oct 2026 23:14:53 -0700 Subject: [PATCH 1/3] feat(web): replace the list pages with one Triage page --- README.md | 4 +- apps/web/src/App.dom.test.tsx | 203 ++++++++++++++++ apps/web/src/App.tsx | 57 +++-- apps/web/src/components/Layout.tsx | 124 ++++------ apps/web/src/components/inbox-icons.tsx | 9 - .../components/repo-panel/PolicyGrants.tsx | 63 +++-- .../src/components/repo-panel/PolicyRules.tsx | 4 +- apps/web/src/lib/inbox-view.test.ts | 58 +++-- apps/web/src/lib/inbox-view.ts | 68 +++++- apps/web/src/lib/open-pulls.test.ts | 9 +- apps/web/src/lib/open-pulls.ts | 43 ++-- apps/web/src/lib/pulse-visit.tsx | 168 ------------- apps/web/src/lib/pulse.test.ts | 83 ------- apps/web/src/lib/pulse.ts | 102 -------- apps/web/src/lib/repo-stats.ts | 4 - apps/web/src/lib/user-cache.tsx | 11 +- apps/web/src/pages/AcceptInvite.tsx | 2 +- apps/web/src/pages/Inbox.tsx | 221 ++++++++++++------ apps/web/src/pages/PRDetail.tsx | 6 +- apps/web/src/pages/Pulse.dom.test.tsx | 9 - apps/web/src/pages/Pulse.tsx | 185 --------------- .../src/pages/host-repo-links.dom.test.tsx | 10 +- apps/web/src/shell.css | 53 ++--- docs/ARCHITECTURE.md | 6 +- .../Corbits Triage User Manual.md | 67 +++--- docs/Corbits Triage User Manual/images/16.png | Bin 122192 -> 0 bytes docs/Corbits Triage User Manual/images/37.png | Bin 79563 -> 0 bytes docs/Corbits Triage User Manual/images/45.png | Bin 76449 -> 0 bytes docs/PRODUCT.md | 20 +- docs/SELF_HOST.md | 2 +- docs/provisioners/docker-remote.md | 2 +- docs/provisioners/railway-sandboxes.md | 2 +- skills/corbits-triage-install/SKILL.md | 4 +- skills/corbits-triage-setup/reference.md | 2 +- 34 files changed, 697 insertions(+), 904 deletions(-) create mode 100644 apps/web/src/App.dom.test.tsx delete mode 100644 apps/web/src/lib/pulse-visit.tsx delete mode 100644 apps/web/src/lib/pulse.test.ts delete mode 100644 apps/web/src/lib/pulse.ts delete mode 100644 apps/web/src/pages/Pulse.dom.test.tsx delete mode 100644 apps/web/src/pages/Pulse.tsx delete mode 100644 docs/Corbits Triage User Manual/images/16.png delete mode 100644 docs/Corbits Triage User Manual/images/37.png delete mode 100644 docs/Corbits Triage User Manual/images/45.png diff --git a/README.md b/README.md index 37d6c653c..b1f8ee956 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/web/src/App.dom.test.tsx b/apps/web/src/App.dom.test.tsx new file mode 100644 index 000000000..bfd64f586 --- /dev/null +++ b/apps/web/src/App.dom.test.tsx @@ -0,0 +1,203 @@ +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 { + 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; }, +})); + +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()); +} + +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.each(["/runs/abc/def", "/triage/acme/widgets"])("%s opens Triage", (path) => { + renderAt(path); + + expect(window.location.pathname).toBe("/"); + expect(document.querySelector("h1")?.textContent).toBe("Triage"); +}); + +test("an owner named like an app page keeps its pull request address", () => { + renderAt("/settings/a/1"); + + expect(window.location.pathname).toBe("/pr/settings/a/1"); +}); + +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([]); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index be7050cd8..61af48326 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Button } from "@corbits/react-ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "@corbits/react-ui/ui/toast"; -import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; +import { BrowserRouter, Navigate, Route, Routes, useLocation, useParams } from "react-router-dom"; import { INVITE_PATH, PortalProvider, usePortal } from "./lib/portal.tsx"; import { HeldInboxProvider, useSignOutAfterSending } from "./lib/held-inbox.tsx"; import { SessionProvider, useSession } from "./lib/session.tsx"; @@ -24,12 +24,29 @@ 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 ; +function LegacyListRedirect() { + const { pathname, search } = useLocation(); + return ; +} + +const TRIAGE_ROOTS = new Set(["triage", "queue", "runs", "approvals"]); + +/** `/:owner/:repo/:number` outranks splat routes, so the old list roots are told apart from owners here. */ +function SelectedPullRedirect() { + const { pathname, search } = useLocation(); + const { owner } = useParams(); + const legacy = legacyTriageTarget(pathname, search); + if (legacy !== null) return ; + if (owner !== undefined && TRIAGE_ROOTS.has(owner)) return ; + return ; +} + +function TriageRedirect() { + return ; } function NotConfigured() { @@ -145,37 +162,39 @@ function Gate() { if (snapshot.denied.repos || snapshot.denied.credentials || snapshot.denied.models) { return ; } - const entry = path === "/" || path === "/login"; if (!connected || path === "/connect") return ; if (path === "/" && new URLSearchParams(location.search).has("installation_id")) return ; - if (entry) { - return 0 ? "/inbox" : "/repositories"} replace />; + if (path === "/" || path === "/login") { + if (snapshot.repos.length === 0) return ; + if (path === "/login") return ; } return ( - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> - } /> + } /> } /> } /> } /> - } /> + } /> + } /> + } /> Page not found. Choose a page from the navigation.} /> diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx index a91f27117..f5b541299 100644 --- a/apps/web/src/components/Layout.tsx +++ b/apps/web/src/components/Layout.tsx @@ -5,16 +5,15 @@ import { errorText } from "@corbits/common"; import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@corbits/react-ui/ui/menu"; import { Link, NavLink, useLocation, useNavigate } from "react-router-dom"; import { Plus } from "lucide-react"; -import { initialsOf, listCounts } from "../lib/inbox-view.ts"; +import { initialsOf, needsYouCount } from "../lib/inbox-view.ts"; import { useOpenWorkspaceAfterSending, useSignOutAfterSending } from "../lib/held-inbox.tsx"; import { useOpenItems, useOpenPullsUnanswered } from "../lib/open-pulls.ts"; import { createExtraWorkspace, sharesName } from "../lib/hub-api.ts"; import { usePortal } from "../lib/portal.tsx"; -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, RepoIcon, SignOutIcon, UpDownIcon } from "./inbox-icons.tsx"; function WorkspaceSwitcher() { const { snapshot, warn } = usePortal(); @@ -116,12 +115,16 @@ function UserBlock() { ); } +function triageList(path: string): boolean { + return path === "/" || path.startsWith("/pr/"); +} + 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"; - return "inbox"; + if (triageList(path)) return "inbox"; + return "other"; } export default function Layout({ children }: { children: ReactNode }) { @@ -129,16 +132,14 @@ export default function Layout({ children }: { children: ReactNode }) { const { snapshot } = usePortal(); const open = useOpenItems(); const enabled = useMemo(() => triageEnabledRepos(snapshot?.repos ?? []), [snapshot]); - const counts = useMemo(() => listCounts(open, enabled), [open, enabled]); + const needsYou = useMemo(() => needsYouCount(open, enabled), [open, enabled]); const openPullsUnanswered = useOpenPullsUnanswered(); const path = location.pathname; const pullPage = path.startsWith("/triage/pr/") || path.startsWith("/prs/"); const room = roomOf(path, pullPage); - const needsYouActive = path.startsWith("/inbox") || path.startsWith("/triage") || pullPage; - const readyActive = path.startsWith("/ready"); - const allOpenActive = path.startsWith("/open"); - // The inbox and the repositories table draw their own inset panels, so they skip the stage card. - const ownPanels = room === "inbox" || room === "repos"; + const triageActive = room === "inbox" || room === "pr"; + // The triage list and the repositories table draw their own inset panels, so they skip the stage card. Audit does too. + const ownPanels = room === "inbox" || room === "repos" || room === "other"; const stageClass = room === "pr" ? "stage has-sliver" : "stage"; useEffect(function markBody() { @@ -149,68 +150,43 @@ export default function Layout({ children }: { children: ReactNode }) { }, [room]); return ( - -
- - Skip to content - - - {ownPanels ? children : ( -
- {children} -
- )} - -
-
+
+ + Skip to content + + + {ownPanels ? children : ( +
+ {children} +
+ )} + +
); } diff --git a/apps/web/src/components/inbox-icons.tsx b/apps/web/src/components/inbox-icons.tsx index bbd1190ce..927195d76 100644 --- a/apps/web/src/components/inbox-icons.tsx +++ b/apps/web/src/components/inbox-icons.tsx @@ -24,18 +24,9 @@ export function ExternalIcon() { export function CheckIcon() { return ; } -export function PulseIcon() { - return ; -} export function InboxIcon() { return ; } -export function ListIcon() { - return ; -} -export function MergeIcon() { - return ; -} export function RepoIcon() { return ; } diff --git a/apps/web/src/components/repo-panel/PolicyGrants.tsx b/apps/web/src/components/repo-panel/PolicyGrants.tsx index f1329e81f..982160361 100644 --- a/apps/web/src/components/repo-panel/PolicyGrants.tsx +++ b/apps/web/src/components/repo-panel/PolicyGrants.tsx @@ -1,13 +1,14 @@ import type { ReactNode } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query"; import { Button } from "@corbits/react-ui"; import { errorText } from "@corbits/common"; import { alwaysHeld, DO_GRANT_ACTION, doResource, hostKinds, VERDICTS, type ActionKind, type CheckPack, type MergeRisk, type RepoSource } from "@corbits/triage-contracts"; import { findTriageRole } from "../../lib/automation-grid.ts"; import { checkPackQuery } from "../../lib/check-packs.ts"; +import type { CheckPackArtifact, HubRole } from "../../lib/hub-api.ts"; import { doName, postingKinds } from "../../lib/policy-view.ts"; import { usePortal } from "../../lib/portal.tsx"; -import { useAllowed, useRoles } from "../../lib/tenant-entities.ts"; +import { useAllowed, useRoles, type TenantSection } from "../../lib/tenant-entities.ts"; function policyKinds(pack: CheckPack, decide: readonly ActionKind[], source: RepoSource): ActionKind[] { const steps = VERDICTS.flatMap((verdict) => pack.actions[verdict].flatMap((step) => (step.do === "nothing" || alwaysHeld(verdict, step.do) ? [] : [step.do]))); @@ -17,13 +18,41 @@ function policyKinds(pack: CheckPack, decide: readonly ActionKind[], source: Rep const RISK_NAMES: Record = { "first-timers": "pull requests from first-time contributors", "ci-config": "changes to CI workflows" }; +export type GrantsReading = { text: string } | { roleId: string; kinds: ActionKind[]; missing: ActionKind[]; risks: readonly MergeRisk[] | undefined }; + +type PolicyResult = Pick, "isPending" | "isError" | "data">; + +export function readGrants(policy: PolicyResult, roles: TenantSection, source: RepoSource): GrantsReading { + if (policy.isPending || roles.loading) return { text: "Checking what Triage may do here…" }; + if (policy.isError) return { text: "Could not read what Triage may do here, so it asks before every action." }; + const found = policy.data; + if (found?.kind !== "file" && found?.kind !== "pack") return { text: "This repository has no policy to allow, so Triage asks before every action." }; + if (!found.automatic || roles.unavailable || roles.denied) return { text: "Could not read what Triage may do here, so it asks before every action." }; + const triage = findTriageRole(roles.rows); + if (!triage) return { text: "This workspace has no Triage role, so Triage asks before every action." }; + const kinds = policyKinds(found.pack, found.decide ?? [], source); + return { roleId: triage.id, kinds, missing: kinds.filter((kind) => !found.automatic?.includes(kind)), risks: found.mergeRisks }; +} + +export function missesGrants(reading: GrantsReading): boolean { + return "missing" in reading && reading.missing.length > 0; +} + type PolicyGrantsProps = { repo: string; source: RepoSource; disabled: boolean }; export default function PolicyGrants({ repo, source, disabled }: PolicyGrantsProps) { + const { snapshot } = usePortal(); + const policy = useQuery(checkPackQuery(snapshot?.workspace.tenantId, repo, source)); + const roles = useRoles(); + return ; +} + +/** `listIndex` places the card in a list of repositories, which names the repository and keeps its heading id unique. */ +type GrantsCardProps = { repo: string; source: RepoSource; disabled: boolean; reading: GrantsReading; listIndex?: number }; + +export function GrantsCard({ repo, source, disabled, reading, listIndex }: GrantsCardProps) { const { addGrant, snapshot } = usePortal(); const tenantId = snapshot?.workspace.tenantId; - const policy = useQuery(checkPackQuery(tenantId, repo, source)); - const roles = useRoles(); const canCreate = useAllowed("grant:*", "create"); const queryClient = useQueryClient(); const allow = useMutation({ @@ -47,32 +76,28 @@ export default function PolicyGrants({ repo, source, disabled }: PolicyGrantsPro } function body() { - if (policy.isPending || roles.loading) return sentence(Checking what Triage may do here…); - if (policy.isError) return sentence(Could not read what Triage may do here, so it asks before every action.); - const found = policy.data; - if (found?.kind !== "file" && found?.kind !== "pack") return sentence(This repository has no policy to allow, so Triage asks before every action.); - if (!found.automatic || roles.unavailable || roles.denied) return sentence(Could not read what Triage may do here, so it asks before every action.); - const triage = findTriageRole(roles.rows); - if (!triage) return sentence(This workspace has no Triage role, so Triage asks before every action.); - const kinds = policyKinds(found.pack, found.decide ?? [], source); - const missing = kinds.filter((kind) => !found.automatic?.includes(kind)); - if (missing.length === 0) return <>{sentence(Triage does these on its own: {names(kinds)}.)}{caution(found.mergeRisks)}; + if ("text" in reading) return sentence({reading.text}); + if (reading.missing.length === 0) return <>{sentence(Triage does these on its own: {names(reading.kinds)}.)}{caution(reading.risks)}; + const { roleId, missing } = reading; return ( <> {sentence( <> - This policy wants Triage to: {names(missing)}. Until you allow it, these wait for you in the inbox. - + This policy wants Triage to: {names(missing)}. Until you allow it, these wait for you on Triage. + , )} - {caution(found.mergeRisks)} + {caution(reading.risks)} ); } + const listed = listIndex !== undefined; + const headingId = listed ? `grants-${`${source}:${repo}`.replace(/[^A-Za-z0-9_-]/g, "-")}-${listIndex}` : "rs-alone"; + return ( -
-

Actions Triage takes on its own

+
+

Actions Triage takes on its own{listed ? ` in ${repo}` : ""}

{body()} diff --git a/apps/web/src/components/repo-panel/PolicyRules.tsx b/apps/web/src/components/repo-panel/PolicyRules.tsx index 9f37419da..8ee9d1b09 100644 --- a/apps/web/src/components/repo-panel/PolicyRules.tsx +++ b/apps/web/src/components/repo-panel/PolicyRules.tsx @@ -40,10 +40,10 @@ function customText(row: CustomCheck): string { return row.kind === "model" && row.shape === "is-true" ? row.claim : customSentence(row); } -const WAITS = " (waits for you in the Inbox)"; +const WAITS = " (waits for you on Triage)"; /** - * One line of the verdict Triage posts before any step. Posting it from the Inbox writes only the comment and its + * One line of the verdict Triage posts before any step. Posting it from Triage writes only the comment and its * labels, so a label waits there only beside a waiting comment, and a build status never does. */ function postText(pack: CheckPack, source: RepoSource, verdict: VerdictWord, kind: ActionKind, allowed: (kind: ActionKind) => boolean): string { diff --git a/apps/web/src/lib/inbox-view.test.ts b/apps/web/src/lib/inbox-view.test.ts index a28feb153..48bd492a9 100644 --- a/apps/web/src/lib/inbox-view.test.ts +++ b/apps/web/src/lib/inbox-view.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { CheckResult, PrItem } from "./hub-api.ts"; import { withoutDecidedDos } from "./pending-dos.ts"; -import { allOpen, groupInbox, groupOpen, inboxAction, inboxStatus, inNeedsYou, listCounts, needsYou, notPostedText, postedLabel, primaryAction, readyToMerge, waitingOnOthers, waitsOn, waitsOnIn, type OpenItem } from "./inbox-view.ts"; +import { allOpen, entriesFor, groupInbox, groupOpen, inboxAction, inboxStatus, inNeedsYou, legacyTriageTarget, needsYouCount, needsYou, notPostedText, postedLabel, primaryAction, readyToMerge, triageView, waitingOnOthers, waitsOn, waitsOnIn, withTriageView, type OpenItem } from "./inbox-view.ts"; const READY = new Set(["acme/widgets"]); @@ -148,7 +148,7 @@ describe("requested changes", () => { test("leave Needs you and wait on the author until the verdict sees an answer", () => { expect(groupInbox([reviewed], READY).groups).toEqual([]); - expect(listCounts(entries(reviewed), READY)["needs-you"]).toBe(0); + expect(needsYouCount(entries(reviewed), READY)).toBe(0); expect(waitsOn(reviewed, null)).toBe("author"); }); @@ -165,18 +165,18 @@ describe("requested changes", () => { test("a pending approval keeps it in Needs you", () => { const pending = { ...reviewed, pendingApprovalId: "a1" }; - expect(listCounts(entries(pending), READY)["needs-you"]).toBe(1); + expect(needsYouCount(entries(pending), READY)).toBe(1); expect(waitsOn(pending, null)).toBe("maintainer"); }); test("a held action keeps it in Needs you until it is approved, refused or skipped", () => { const action = { list: "fail", index: 0, effectId: "e", kind: "request-changes", reason: "Size failed.", target: { body: "Split this." }, held: true } as const; const held = { ...reviewed, posted: true, actions: [action] }; - expect(listCounts(entries(held), READY)["needs-you"]).toBe(1); - expect(listCounts(entries({ ...held, actions: [{ ...action, status: "failed" }] }), READY)["needs-you"]).toBe(1); - expect(listCounts(entries({ ...held, actions: [{ ...action, status: "done" }] }), READY)["needs-you"]).toBe(0); - expect(listCounts(entries({ ...held, actions: [{ ...action, status: "skipped" }] }), READY)["needs-you"]).toBe(0); - expect(listCounts(entries(withoutDecidedDos(held, new Set(["e"]), new Set())), READY)["needs-you"]).toBe(0); + expect(needsYouCount(entries(held), READY)).toBe(1); + expect(needsYouCount(entries({ ...held, actions: [{ ...action, status: "failed" }] }), READY)).toBe(1); + expect(needsYouCount(entries({ ...held, actions: [{ ...action, status: "done" }] }), READY)).toBe(0); + expect(needsYouCount(entries({ ...held, actions: [{ ...action, status: "skipped" }] }), READY)).toBe(0); + expect(needsYouCount(entries(withoutDecidedDos(held, new Set(["e"]), new Set())), READY)).toBe(0); }); }); @@ -233,10 +233,10 @@ describe("All open", () => { expect(allOpen(open, new Set())).toEqual([]); }); - test("Needs you and All open count only repositories with triage enabled", () => { - expect(listCounts(open, READY)).toEqual({ "needs-you": 1, ready: 0, "all-open": 3 }); - expect(listCounts(open, new Set())).toEqual({ "needs-you": 0, ready: 0, "all-open": 0 }); - expect(listCounts([], READY)).toEqual({ "needs-you": 0, ready: 0, "all-open": 0 }); + test("Needs you counts only repositories with triage enabled", () => { + expect(needsYouCount(open, READY)).toBe(1); + expect(needsYouCount(open, new Set())).toBe(0); + expect(needsYouCount([], READY)).toBe(0); }); test("Waiting lists every open pull request outside Needs you, each with what it waits on", () => { @@ -256,7 +256,7 @@ describe("All open", () => { ["acme/widgets#6", "ci"], ]); expect(needsYou(waiting, READY).filter((entry) => inNeedsYou(entry.item)).map((entry) => entry.item.key)).toEqual(["acme/widgets#1", "acme/widgets#7", "acme/widgets#8", "acme/widgets#9", "acme/off#10"]); - expect(listCounts(waiting, READY)["needs-you"] + waitingOnOthers(waiting, READY).length).toBe(allOpen(waiting, READY).length); + expect(needsYouCount(waiting, READY) + waitingOnOthers(waiting, READY).length).toBe(allOpen(waiting, READY).length); }); test("an untriaged pull request in a repository that is not set up waits on its setup", () => { @@ -287,7 +287,14 @@ describe("Ready", () => { passed(8, { key: "acme/off#8", repo: "acme/off" }), ]; expect(readyToMerge(open, READY).map((entry) => entry.item.number)).toEqual([2, 1]); - expect(listCounts(open, READY).ready).toBe(2); + }); + + test("All keeps a pull request merged here, and like the other views only repositories with triage enabled", () => { + const open = [passed(1, {}), passed(2, { waitingSince: "2026-10-02T00:00:00.000Z" })]; + const known = [...open, { item: item({ key: "acme/widgets#9", number: 9 }), handled: { kind: "merge" as const } }, passed(10, { key: "acme/off#10", repo: "acme/off" })]; + expect(entriesFor("ready", open, known, READY).map((entry) => entry.item.number)).toEqual(readyToMerge(open, READY).map((entry) => entry.item.number)); + expect(entriesFor("all-open", open, known, READY)).toEqual(allOpen(open, READY)); + expect(entriesFor("all", open, known, READY).map((entry) => entry.item.number)).toEqual([1, 2, 9]); }); test("a pass the host would not merge leaves Ready and Merge for where it gets unblocked", () => { @@ -300,3 +307,26 @@ describe("Ready", () => { ]); }); }); + +describe("Triage address", () => { + test("Needs you has no filter, and Ready, Open and All are search filters", () => { + expect(triageView(new URLSearchParams())).toBe("needs-you"); + expect(triageView(new URLSearchParams("view=ready"))).toBe("ready"); + expect(triageView(new URLSearchParams("view=open"))).toBe("all-open"); + expect(triageView(new URLSearchParams("view=all"))).toBe("all"); + expect(triageView(new URLSearchParams("view=nope"))).toBe("needs-you"); + expect(withTriageView(new URLSearchParams("waiting=1&repo=acme"), "ready").toString()).toBe("repo=acme&view=ready"); + expect(withTriageView(new URLSearchParams("waiting=1"), "needs-you").toString()).toBe(""); + expect(withTriageView(new URLSearchParams("view=all&repo=acme"), "needs-you").toString()).toBe("repo=acme"); + }); + + test("the old list addresses open the same pull request on Triage", () => { + expect(legacyTriageTarget("/pulse", "")).toBe("/"); + expect(legacyTriageTarget("/inbox", "?waiting=1")).toBe("/?waiting=1"); + expect(legacyTriageTarget("/inbox/acme/widgets/4", "")).toBe("/pr/acme/widgets/4"); + expect(legacyTriageTarget("/ready", "")).toBe("/?view=ready"); + expect(legacyTriageTarget("/ready/acme/widgets/4", "?repo=acme")).toBe("/pr/acme/widgets/4?repo=acme&view=ready"); + expect(legacyTriageTarget("/open/acme/widgets/4", "")).toBe("/pr/acme/widgets/4?view=open"); + expect(legacyTriageTarget("/settings", "")).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/inbox-view.ts b/apps/web/src/lib/inbox-view.ts index 073e98062..e3c51c61e 100644 --- a/apps/web/src/lib/inbox-view.ts +++ b/apps/web/src/lib/inbox-view.ts @@ -265,14 +265,50 @@ export function matchesQuery(item: PrItem, query: string): boolean { export type ListView = "needs-you" | "ready" | "all-open"; -const LIST_ROOT: Record = { "needs-you": "/inbox", ready: "/ready", "all-open": "/open" }; +/** Ready, Open and All are filters on Triage. Open is the old All open list. All is every open pull request across the repositories Triage runs on. */ +export type TriageView = ListView | "all"; -export function listRoot(view: ListView): string { - return LIST_ROOT[view]; +const VIEW_PARAM = "view"; + +const FILTER_PARAM: Record, string> = { ready: "ready", "all-open": "open", all: "all" }; + +const VIEW_FROM_PARAM: Record = { ready: "ready", open: "all-open", all: "all" }; + +/** Needs you has no filter in the address. Anything else is ignored. */ +export function triageView(params: URLSearchParams): TriageView { + const named = params.get(VIEW_PARAM); + return named !== null && Object.hasOwn(VIEW_FROM_PARAM, named) ? VIEW_FROM_PARAM[named]! : "needs-you"; } -export function listHref(view: ListView, item: Pick): string { - return `${LIST_ROOT[view]}/${item.repo}/${item.number ?? ""}`; +/** Keeps the other search filters. Waiting is only the Waiting tab, so choosing any slice drops it. */ +export function withTriageView(params: URLSearchParams, view: TriageView): URLSearchParams { + const next = new URLSearchParams(params); + if (view === "needs-you") next.delete(VIEW_PARAM); + else next.set(VIEW_PARAM, FILTER_PARAM[view]); + next.delete("waiting"); + return next; +} + +/** The list lives at `/`. A selected pull request is under `/pr/`, and the filter stays in the search string. A row with no number has no address. */ +export function listHref(item: Pick): string | undefined { + if (item.number === null) return undefined; + return `/pr/${item.repo}/${item.number}`; +} + +/** + * Pulse, Inbox, Ready and Open, including a pull request open on them, become the same place on Triage. + * Returns null when the path is not one of those pages. + */ +export function legacyTriageTarget(pathname: string, search: string): string | null { + const parts = pathname.split("/").filter(Boolean); + const root = parts[0]; + if (root !== "pulse" && root !== "inbox" && root !== "ready" && root !== "open") return null; + const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + if (root === "ready" || root === "open") params.set(VIEW_PARAM, root); + const rest = root === "pulse" ? [] : parts.slice(1); + const path = rest.length === 3 ? `/pr/${rest.join("/")}` : "/"; + const query = params.toString(); + return query === "" ? path : `${path}?${query}`; } export function repoOf(key: string): string { @@ -310,12 +346,22 @@ export function readyToMerge(open: OpenItem[], enabled: ReadonlySet): Op return allOpen(open, enabled).filter((entry) => isReadyToMerge(entry.item)).sort((a, b) => waitingSince(a.item) - waitingSince(b.item)); } -export function listCounts(open: OpenItem[], enabled: ReadonlySet): Record { - return { - "needs-you": needsYou(open, enabled).filter((entry) => inNeedsYou(entry.item)).length, - ready: readyToMerge(open, enabled).length, - "all-open": allOpen(open, enabled).length, - }; +export function needsYouCount(open: OpenItem[], enabled: ReadonlySet): number { + return needsYou(open, enabled).filter((entry) => inNeedsYou(entry.item)).length; +} + +/** Needs you, Ready and Open match the old lists. All is every open pull request across the repositories Triage runs on, plus any merged or closed here before the next refresh. */ +export function entriesFor(view: TriageView, open: OpenItem[], known: OpenItem[], enabled: ReadonlySet): OpenItem[] { + switch (view) { + case "needs-you": + return needsYou(open, enabled); + case "ready": + return readyToMerge(open, enabled); + case "all-open": + return allOpen(open, enabled); + case "all": + return allOpen(known, enabled); + } } export const AUTHOR_KIND_LABEL: Record = { diff --git a/apps/web/src/lib/open-pulls.test.ts b/apps/web/src/lib/open-pulls.test.ts index 8eac096ac..ead796d42 100644 --- a/apps/web/src/lib/open-pulls.test.ts +++ b/apps/web/src/lib/open-pulls.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { projectQueue, type OpenPulls, type PullTriage } from "./hub-api.ts"; -import { openItems, withPull, type Handled } from "./open-pulls.ts"; +import { knownItems, openItems, withPull, type Handled } from "./open-pulls.ts"; const KEY = "acme/widgets#8"; @@ -57,6 +57,13 @@ describe("handled pull requests", () => { expect(marksOf(PUSHED, open("a"), {}, withPull({}, item, "close"))).toEqual([]); }); + test("All keeps one merged here until the next refresh, but never one already closed", () => { + const [mergedHere, closed] = projectQueue([PUSHED], [], open("a", [8, 9])); + if (mergedHere === undefined || closed === undefined) throw new Error("no items"); + const known = knownItems([mergedHere, { ...closed, closed: true }], withPull({}, mergedHere, "merge"), {}); + expect(known.map((entry) => entry.item.key)).toEqual([KEY]); + }); + test("an entry stored before kinds were recorded matches on the verdict run it answered", () => { expect(marksOf(PUSHED, open("a"), { [KEY]: "run-1" })).toEqual([[KEY, { kind: null }]]); expect(marksOf(latest("run-2", "2026-10-01T00:00:00.000Z"), open("a"), { [KEY]: "run-1" })).toEqual([[KEY, null]]); diff --git a/apps/web/src/lib/open-pulls.ts b/apps/web/src/lib/open-pulls.ts index c87a93418..0eef74853 100644 --- a/apps/web/src/lib/open-pulls.ts +++ b/apps/web/src/lib/open-pulls.ts @@ -71,10 +71,8 @@ export type Handled = Record; const HANDLED_PULLS_QUERY_KEY = "handled-pulls"; /** Held actions can still be undone, so they stay in memory; a pull request moves to the persisted handled map once its send starts. */ const HELD_PULLS_QUERY_KEY = "held-pulls"; -/** The pull requests acted on from the inbox since the last visit ended; kept in memory and saved with the visit. */ -const ACTED_PULLS_QUERY_KEY = "acted-pulls"; -/** Merged and closed pull requests leave every list, not just the inbox. */ +/** Merged and closed pull requests leave the open lists. All keeps one acted on here until the next refresh. */ const LEAVING: ReadonlySet = new Set(["merge", "close"]); export function leavesOpen(kind: HandledKind): boolean { @@ -89,25 +87,6 @@ function heldKey(tenantId: string | undefined) { return [HELD_PULLS_QUERY_KEY, tenantId]; } -function actedKey(tenantId: string | undefined) { - return [ACTED_PULLS_QUERY_KEY, tenantId]; -} - -function noneActed(): string[] { - return []; -} - -export function useActedPulls(): { acted: string[]; replace: (keys: string[]) => void } { - const { snapshot } = usePortal(); - const queryClient = useQueryClient(); - const tenantId = snapshot?.workspace.tenantId; - const { data } = useQuery({ queryKey: actedKey(tenantId), queryFn: noneActed, staleTime: "static", gcTime: Infinity }); - const replace = useCallback(function replaceActed(keys: string[]) { - queryClient.setQueryData(actedKey(tenantId), keys); - }, [queryClient, tenantId]); - return { acted: data ?? [], replace }; -} - /** Kept in IndexedDB, so an approved or commented pull request stays out of the inbox after a reload. */ export function isHandledPullsQuery(query: Query): boolean { return query.queryKey[0] === HANDLED_PULLS_QUERY_KEY; @@ -194,12 +173,10 @@ export function useHandledPulls(): HandledPulls { function markHandled(item: PrItem, kind: HandledKind) { queryClient.setQueryData(handledKey(tenantId), (handled) => withPull(handled, item, kind)); queryClient.setQueryData(heldKey(tenantId), (held) => withoutPull(held, item)); - queryClient.setQueryData(actedKey(tenantId), (acted = []) => (acted.includes(item.key) ? acted : [...acted, item.key])); } function release(item: PrItem) { queryClient.setQueryData(heldKey(tenantId), (held) => withoutPull(held, item)); queryClient.setQueryData(handledKey(tenantId), (handled) => withoutPull(handled, item)); - queryClient.setQueryData(actedKey(tenantId), (acted = []) => acted.filter((key) => key !== item.key)); } function sent(kind: HandledKind) { if (LEAVING.has(kind)) void queryClient.invalidateQueries({ queryKey: [OPEN_PULLS_QUERY_KEY, tenantId] }); @@ -271,7 +248,14 @@ export function openItems(items: PrItem[], handled: Handled, held: Handled): Ope .filter((open) => !LEAVING.has(open.handled?.kind ?? null)); } -export function useOpenItems(): OpenItem[] { +/** Every open pull request in the queue, with the ones merged or closed here still in it until the next refresh. */ +export function knownItems(items: PrItem[], handled: Handled, held: Handled): OpenItem[] { + return items + .filter((item) => !item.closed) + .map((item) => ({ item, handled: handledMark(held, item) ?? handledMark(handled, item) })); +} + +export function useMarkedItems(): { open: OpenItem[]; known: OpenItem[] } { const { snapshot } = usePortal(); const tenantId = snapshot?.workspace.tenantId; const items = usePullRequestItems(); @@ -279,11 +263,16 @@ export function useOpenItems(): OpenItem[] { const handled = useHandledQuery(handledKey(tenantId)); const held = useHandledQuery(heldKey(tenantId)); usePruneHandled(items, handled); - return useMemo(function openWithMarks() { - return openPullsUnanswered ? [] : openItems(items, handled, held); + return useMemo(function markItems() { + if (openPullsUnanswered) return { open: [], known: [] }; + return { open: openItems(items, handled, held), known: knownItems(items, handled, held) }; }, [items, openPullsUnanswered, handled, held]); } +export function useOpenItems(): OpenItem[] { + return useMarkedItems().open; +} + export function useQueueItems(): PrItem[] { const open = useOpenItems(); return useMemo(() => open.filter((entry) => entry.handled === null).map((entry) => entry.item), [open]); diff --git a/apps/web/src/lib/pulse-visit.tsx b/apps/web/src/lib/pulse-visit.tsx deleted file mode 100644 index a82458a01..000000000 --- a/apps/web/src/lib/pulse-visit.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from "react"; -import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { PrItem } from "./hub-api.ts"; -import { allOpen, type OpenItem } from "./inbox-view.ts"; -import { useActedPulls, useOpenPulls, useQueueLoading } from "./open-pulls.ts"; -import { usePortal } from "./portal.tsx"; -import { parseSavedPulse, resumeVisit, sinceVisit, stuckPulls, visitOf, type SavedPulse, type SinceVisit, type Visit, type VisitStart } from "./pulse.ts"; -import { triageEnabledRepos } from "./repo-rows.ts"; -import { useSession } from "./session.tsx"; - -const VISIT_QUERY_KEY = "pulse-visit"; - -const STORAGE_PREFIX = "corbits-triage:pulse:"; - -function storageKey(userId: string, tenantId: string): string { - return `${STORAGE_PREFIX}${userId}:${tenantId}`; -} - -/** Saved visits hold pull request titles, so they are removed when the session ends. */ -export function forgetSavedVisits(storage: Storage) { - const keys = Array.from({ length: storage.length }, (_, index) => storage.key(index)); - for (const key of keys) { - if (key?.startsWith(STORAGE_PREFIX)) storage.removeItem(key); - } -} - -function readSaved(key: string): SavedPulse | null { - try { - return parseSavedPulse(localStorage.getItem(key)); - } catch { - return null; - } -} - -function writeSaved(key: string, saved: SavedPulse) { - try { - localStorage.setItem(key, JSON.stringify(saved)); - } catch { - // Storage can be blocked (private mode, quota); the visit then lasts for this page only. - } -} - -/** `firstVisit` is true while the compared visit started on the user's first visit, so nothing can have changed yet. */ -export type Pulse = { visit: Visit; firstVisit: boolean; since: SinceVisit; stuck: PrItem[]; acted: number }; - -type PulseState = { - pulse: Pulse | null; - endVisit: () => void; - saveVisit: () => void; -}; - -const PulseContext = createContext(null); - -type VisitArea = "pulse" | "inbox"; - -function visitArea(path: string): VisitArea | null { - if (path.startsWith("/pulse")) return "pulse"; - if (["/inbox", "/open", "/triage"].some((root) => path.startsWith(root))) return "inbox"; - return null; -} - -function usePulseState(open: OpenItem[]): PulseState { - const { session } = useSession(); - const { snapshot } = usePortal(); - const queryClient = useQueryClient(); - const loading = useQueueLoading(); - const openPulls = useOpenPulls(); - const { acted, replace } = useActedPulls(); - const tenantId = snapshot?.workspace.tenantId; - const storage = session && tenantId ? storageKey(session.id, tenantId) : null; - const visitQueryKey = useMemo(() => [VISIT_QUERY_KEY, storage], [storage]); - const { data: start } = useQuery({ - queryKey: visitQueryKey, - queryFn: storage === null ? skipToken : function loadVisit(): VisitStart | null { - const saved = readSaved(storage); - return saved === null ? null : resumeVisit(saved, Date.now()); - }, - staleTime: "static", - gcTime: Infinity, - }); - const entries = useMemo(() => allOpen(open, triageEnabledRepos(snapshot?.repos ?? [])), [open, snapshot]); - const read = useMemo(function answeredRepos() { - const enabled = triageEnabledRepos(snapshot?.repos ?? []); - return new Set((openPulls.data?.repos ?? []).filter((repo) => repo.error === undefined && enabled.has(repo.repo)).map((repo) => repo.repo)); - }, [openPulls.data, snapshot]); - // The open pull requests restored from IndexedDB may be stale, so nothing is compared or stored before GitHub answers. - const answered = !loading && openPulls.isFetchedAfterMount; - const ready = answered && start !== undefined; - - useEffect(function actedSinceVisitStart() { - if (start) replace(start.acted); - }, [start, replace]); - - const pulse = useMemo(function pulseSinceVisit() { - if (!ready || start === null) return null; - const visit = start.compared; - return { visit, firstVisit: start.first, since: sinceVisit(visit, entries, read), stuck: stuckPulls(entries, Date.now()), acted: acted.length }; - }, [ready, start, entries, read, acted]); - - const latest = useCallback(function latest(): Visit | null { - return answered && storage !== null ? visitOf(entries, read, new Date()) : null; - }, [answered, entries, read, storage]); - - const restart = useCallback(function restart(first: boolean) { - const now = latest(); - if (now === null || storage === null) return; - const next = { compared: now, acted: [], first }; - writeSaved(storage, { ...next, latest: now }); - queryClient.setQueryData(visitQueryKey, next); - }, [latest, queryClient, storage, visitQueryKey]); - - useEffect(function startFirstVisit() { - if (ready && start === null) restart(true); - }, [ready, start, restart]); - - const endVisit = useCallback(() => restart(false), [restart]); - - const saveVisit = useCallback(function saveVisit() { - const now = latest(); - if (now === null || storage === null || !start) return; - writeSaved(storage, { compared: start.compared, latest: now, acted, first: start.first }); - }, [acted, latest, start, storage]); - - return useMemo(() => ({ pulse, endVisit, saveVisit }), [pulse, endVisit, saveVisit]); -} - -/** - * A visit spans Pulse and the inbox and ends on leaving both. Closing or hiding the page stores it for the next load, - * so a tab switch does not wipe the briefing being read. - */ -function useVisitEnds(path: string, { endVisit, saveVisit }: PulseState) { - const area = visitArea(path); - const previous = useRef(area); - - useEffect(function endVisitOnLeave() { - const was = previous.current; - previous.current = area; - if (was !== null && area === null) endVisit(); - }, [area, endVisit]); - - useEffect(function saveVisitOnHide() { - if (area === null) return; - function onPageHide() { - saveVisit(); - } - function onVisibilityChange() { - if (document.visibilityState === "hidden") saveVisit(); - } - window.addEventListener("pagehide", onPageHide); - document.addEventListener("visibilitychange", onVisibilityChange); - return function stopListening() { - window.removeEventListener("pagehide", onPageHide); - document.removeEventListener("visibilitychange", onVisibilityChange); - }; - }, [area, saveVisit]); -} - -export function PulseProvider({ open, path, children }: { open: OpenItem[]; path: string; children: ReactNode }) { - const state = usePulseState(open); - useVisitEnds(path, state); - return {children}; -} - -export function usePulse(): PulseState { - const state = useContext(PulseContext); - if (state === null) throw new Error("usePulse needs a PulseProvider."); - return state; -} diff --git a/apps/web/src/lib/pulse.test.ts b/apps/web/src/lib/pulse.test.ts deleted file mode 100644 index d55e04d09..000000000 --- a/apps/web/src/lib/pulse.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { PrItem } from "./hub-api.ts"; -import type { OpenItem } from "./inbox-view.ts"; -import { CONTINUE_MS, parseSavedPulse, resumeVisit, sinceVisit, stuckPulls, visitOf, type Visit } from "./pulse.ts"; - -const NOW = Date.parse("2026-10-10T12:00:00.000Z"); -const READ = new Set(["acme/api", "acme/web"]); - -function open(key: string, overrides: Partial = {}, handled: OpenItem["handled"] = null): OpenItem { - const item = { - key, repo: key.slice(0, key.indexOf("#")), number: Number(key.slice(key.indexOf("#") + 1)), title: `Title ${key}`, state: "needs-decision", - draft: false, pendingApprovalId: null, actions: [], ci: null, failure: null, waitingSince: "2026-10-09T12:00:00.000Z", ...overrides, - } as PrItem; - return { item, handled }; -} - -describe("sinceVisit", () => { - const visit: Visit = { - at: "2026-10-09T12:00:00.000Z", - repos: ["acme/api", "acme/web"], - pulls: { - "acme/api#1": { title: "Kept", you: false }, - "acme/api#2": { title: "Merged", you: true }, - "acme/web#3": { title: "Read failed", you: true }, - }, - }; - - test("counts new, resolved and turned to you only in repositories compared both times", () => { - const now = [open("acme/api#1"), open("acme/api#4"), open("acme/new#5")]; - const since = sinceVisit(visit, now, new Set(["acme/api", "acme/new"])); - expect(since.fresh.map((item) => item.key)).toEqual(["acme/api#4"]); - expect(since.resolved).toEqual([{ key: "acme/api#2", repo: "acme/api", number: 2, title: "Merged", url: null }]); - expect(since.turned.map((item) => item.key)).toEqual(["acme/api#1"]); - }); - - test("a pull request handed to the author is not turned to you", () => { - expect(sinceVisit(visit, [open("acme/api#1", {}, { kind: "changes" })], READ).turned).toEqual([]); - }); - - test("the visit taken now shows no change", () => { - const now = [open("acme/api#1"), open("acme/web#3", {}, { kind: "changes" })]; - const since = sinceVisit(visitOf(now, READ, new Date(NOW)), now, READ); - expect([since.fresh, since.resolved, since.turned]).toEqual([[], [], []]); - }); -}); - -describe("stuckPulls", () => { - test("lists pull requests waiting on the maintainer longer than 3 days, oldest first", () => { - const stuck = stuckPulls([ - open("acme/api#1", { waitingSince: "2026-10-07T11:00:00.000Z" }), - open("acme/api#2", { waitingSince: "2026-10-01T00:00:00.000Z" }), - open("acme/api#3", { waitingSince: "2026-10-07T13:00:00.000Z" }), - open("acme/api#4", { waitingSince: "2026-10-01T00:00:00.000Z" }, { kind: "changes" }), - open("acme/api#5", { waitingSince: null }), - ], NOW); - expect(stuck.map((item) => item.key)).toEqual(["acme/api#2", "acme/api#1"]); - }); -}); - -describe("resumeVisit", () => { - const compared = visitOf([open("acme/api#1")], READ, new Date(NOW - 86_400_000)); - const latest = visitOf([open("acme/api#1"), open("acme/api#2")], READ, new Date(NOW)); - const saved = { compared, latest, acted: ["acme/api#1"], first: false }; - - test("a reload or restored tab within 10 minutes of the last save carries the visit on", () => { - expect(resumeVisit(saved, NOW + CONTINUE_MS - 1)).toEqual({ compared, acted: ["acme/api#1"], first: false }); - }); - - test("later, the last save is what the new visit is compared to", () => { - expect(resumeVisit(saved, NOW + CONTINUE_MS)).toEqual({ compared: latest, acted: [], first: false }); - }); -}); - -describe("parseSavedPulse", () => { - test("reads back a saved pulse and rejects anything else", () => { - const visit = visitOf([open("acme/api#1")], READ, new Date(NOW)); - const saved = { compared: visit, latest: visit, acted: [], first: true }; - expect(parseSavedPulse(JSON.stringify(saved))).toEqual(saved); - expect(parseSavedPulse(JSON.stringify(visit))).toBeNull(); - expect(parseSavedPulse("not json")).toBeNull(); - expect(parseSavedPulse(null)).toBeNull(); - }); -}); diff --git a/apps/web/src/lib/pulse.ts b/apps/web/src/lib/pulse.ts deleted file mode 100644 index f03695098..000000000 --- a/apps/web/src/lib/pulse.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { PrItem } from "./hub-api.ts"; -import { repoOf, waitsOn, type OpenItem } from "./inbox-view.ts"; -import { pullUrl } from "./pull-link.ts"; - -/** Waiting on the maintainer longer than this makes a pull request stuck. `waitingSince` restarts with every new verdict, as in the inbox's age filter. */ -const STUCK_MS = 3 * 86_400_000; - -/** A pull request as it stood when the last visit ended; `you` is whether it waited on the maintainer. */ -type VisitPull = { title: string | null; you: boolean; url?: string | null }; - -/** - * The open pull requests when the last visit ended. GitHub gives no opened or closed time, so what changed is the difference to this. - * `repos` lists the repositories that had triage on and that GitHub answered for; only their pull requests are compared. - */ -export type Visit = { at: string; repos: string[]; pulls: Record }; - -export type ResolvedPull = { key: string; repo: string; number: number; title: string | null; url: string | null }; - -export type SinceVisit = { fresh: PrItem[]; resolved: ResolvedPull[]; turned: PrItem[] }; - -function waitsOnYou(entry: OpenItem): boolean { - return waitsOn(entry.item, entry.handled) === "maintainer"; -} - -export function visitOf(open: OpenItem[], read: ReadonlySet, now: Date): Visit { - const pulls = Object.fromEntries(open - .filter((entry) => read.has(entry.item.repo)) - .map((entry) => [entry.item.key, { title: entry.item.title, you: waitsOnYou(entry), url: pullUrl(entry.item) }])); - return { at: now.toISOString(), repos: [...read], pulls }; -} - -/** - * New: open now and not at the last visit, in a repository compared both times. Resolved: open at the last visit and no longer open, - * in a repository GitHub just answered for. Turned to you: open both times and waiting on the maintainer now but not then. - */ -export function sinceVisit(visit: Visit, open: OpenItem[], read: ReadonlySet): SinceVisit { - const before = new Set(visit.repos); - const now = new Map(open.map((entry) => [entry.item.key, entry])); - const fresh = open.filter((entry) => before.has(entry.item.repo) && read.has(entry.item.repo) && !Object.hasOwn(visit.pulls, entry.item.key)); - const turned = open.filter((entry) => visit.pulls[entry.item.key]?.you === false && waitsOnYou(entry)); - const resolved = Object.entries(visit.pulls) - .filter(([key]) => read.has(repoOf(key)) && !now.has(key)) - .map(([key, pull]) => ({ key, repo: repoOf(key), number: Number(key.slice(key.lastIndexOf("#") + 1)), title: pull.title, url: pull.url ?? null })); - return { fresh: fresh.map((entry) => entry.item), resolved, turned: turned.map((entry) => entry.item) }; -} - -export function stuckPulls(open: OpenItem[], now: number): PrItem[] { - return open - .filter((entry) => entry.item.waitingSince !== null && waitsOnYou(entry) && now - Date.parse(entry.item.waitingSince) > STUCK_MS) - .map((entry) => entry.item) - .sort((a, b) => Date.parse(a.waitingSince ?? "") - Date.parse(b.waitingSince ?? "")); -} - -/** Saved within this long, a visit carries on after a reload or a restored tab. */ -export const CONTINUE_MS = 10 * 60_000; - -/** - * What is kept per user: the visit compared to, what was open at the last save, and what was acted on since the compared visit. - * `first` marks a compared visit that started on the user's first visit, so nothing can have changed yet. - */ -export type SavedPulse = { compared: Visit; latest: Visit; acted: string[]; first: boolean }; - -export type VisitStart = Pick; - -/** Saved within `CONTINUE_MS`, the visit carries on; saved earlier, that save is what the new visit is compared to. */ -export function resumeVisit(saved: SavedPulse, now: number): VisitStart { - if (now - Date.parse(saved.latest.at) < CONTINUE_MS) return { compared: saved.compared, acted: saved.acted, first: saved.first }; - return { compared: saved.latest, acted: [], first: false }; -} - -function isVisitPull(value: unknown): value is VisitPull { - if (typeof value !== "object" || value === null) return false; - const pull = value as Record; - return (pull.title === null || typeof pull.title === "string") && typeof pull.you === "boolean"; -} - -function isStrings(value: unknown): value is string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === "string"); -} - -function visitFrom(value: unknown): Visit | null { - if (typeof value !== "object" || value === null) return null; - const visit = value as Record; - if (typeof visit.at !== "string" || !isStrings(visit.repos)) return null; - if (typeof visit.pulls !== "object" || visit.pulls === null || !Object.values(visit.pulls).every(isVisitPull)) return null; - return { at: visit.at, repos: visit.repos, pulls: visit.pulls as Record }; -} - -export function parseSavedPulse(text: string | null): SavedPulse | null { - if (text === null) return null; - try { - const value: unknown = JSON.parse(text); - if (typeof value !== "object" || value === null) return null; - const saved = value as Record; - const compared = visitFrom(saved.compared); - const latest = visitFrom(saved.latest); - if (compared === null || latest === null || !isStrings(saved.acted) || typeof saved.first !== "boolean") return null; - return { compared, latest, acted: saved.acted, first: saved.first }; - } catch { - return null; - } -} diff --git a/apps/web/src/lib/repo-stats.ts b/apps/web/src/lib/repo-stats.ts index affe3b839..9c660f9df 100644 --- a/apps/web/src/lib/repo-stats.ts +++ b/apps/web/src/lib/repo-stats.ts @@ -83,7 +83,3 @@ export function outcomeCounts(medians: OutcomeMedians): string { export function humansText(humans: number): string { return `${humans} ${humans === 1 ? "person" : "people"} involved`; } - -export function actedOn(stats: TriageStats): number { - return Object.values(stats.actions).reduce((sum, action) => sum + action.executed, 0); -} diff --git a/apps/web/src/lib/user-cache.tsx b/apps/web/src/lib/user-cache.tsx index fc3712e6e..a5f9ba5da 100644 --- a/apps/web/src/lib/user-cache.tsx +++ b/apps/web/src/lib/user-cache.tsx @@ -2,10 +2,19 @@ import { useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } fr import { IsRestoringProvider, useQueryClient } from "@tanstack/react-query"; import type { AsyncStorage } from "@tanstack/react-query-persist-client"; import { del, get, set } from "idb-keyval"; -import { forgetSavedVisits } from "./pulse-visit.tsx"; import { createUserCache, sessionState } from "./query-persistence.ts"; import { useSession } from "./session.tsx"; +/** Visits saved by the old Pulse page hold pull request titles, so a browser that kept them drops them at sign-out. */ +const PULSE_VISIT_PREFIX = "corbits-triage:pulse:"; + +function forgetSavedVisits(storage: Storage) { + const keys = Array.from({ length: storage.length }, (_, index) => storage.key(index)); + for (const key of keys) { + if (key?.startsWith(PULSE_VISIT_PREFIX)) storage.removeItem(key); + } +} + const idbStorage: AsyncStorage = { getItem: get, setItem: set, removeItem: del }; export function UserQueryCache({ children }: { children: ReactNode }) { diff --git a/apps/web/src/pages/AcceptInvite.tsx b/apps/web/src/pages/AcceptInvite.tsx index 80fd0786e..1cc5b1096 100644 --- a/apps/web/src/pages/AcceptInvite.tsx +++ b/apps/web/src/pages/AcceptInvite.tsx @@ -42,7 +42,7 @@ export function AcceptInvite({ tenantId }: { tenantId: string }) { if (!(await hasSignInMethod()) && (await getAuthMethods()).passwordReset) { warn("No password is set on this account. Use Reset password on the sign-in page to set one."); } - await openWorkspace(tenantId, "/inbox"); + await openWorkspace(tenantId, "/"); } catch (cause: unknown) { if (cause instanceof ApiError && cause.code === "email_unverified") setUnverified(true); else setRefusal(errorText(cause)); diff --git a/apps/web/src/pages/Inbox.tsx b/apps/web/src/pages/Inbox.tsx index 6be1adf55..a5fd7ed74 100644 --- a/apps/web/src/pages/Inbox.tsx +++ b/apps/web/src/pages/Inbox.tsx @@ -2,8 +2,9 @@ import { Fragment, useEffect, useMemo, useRef, useState, type FormEvent, type Ke import { Button } from "@corbits/react-ui"; import { Menu, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuTrigger } from "@corbits/react-ui/ui/menu"; import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useQueries } from "@tanstack/react-query"; import type { AuthorKind, CheckPack } from "@corbits/triage-contracts"; -import type { GithubPullDetail, PrItem } from "../lib/hub-api.ts"; +import type { GithubPullDetail, PortalSnapshot, PrItem } from "../lib/hub-api.ts"; import { DeniedNotice } from "../lib/denied.tsx"; import { useGithubPull } from "../lib/github-pull.ts"; import { useMediaQuery } from "../lib/media-query.ts"; @@ -42,8 +43,8 @@ import { INBOX_GROUPINGS, OPEN_GROUPINGS, ageText, - allOpen, awaitingText, + entriesFor, groupInbox, groupOpen, inNeedsYou, @@ -51,44 +52,83 @@ import { isLoudAuthorKind, listedAction, listHref, - listRoot, matchesQuery, - needsYou, - readyToMerge, regroupInbox, + triageView, waitingOnOthers, + withTriageView, type InboxAction, type InboxGrouping, type HandledMark, type InboxPile, - type ListView, type OpenGrouping, type OpenItem, type OpenPile, + type TriageView, } from "../lib/inbox-view.ts"; import { NO_FILTERS, activeFilters, filtersFromParams, filtersToParams, matchesFilters, type InboxFilters } from "../lib/inbox-filter.ts"; -import { leavesOpen, useOpenItems, useOpenPulls, useOpenPullsUnanswered, useQueueLoading } from "../lib/open-pulls.ts"; +import { leavesOpen, useOpenPulls, useOpenPullsUnanswered, useQueueLoading, useMarkedItems } from "../lib/open-pulls.ts"; import { usePortal } from "../lib/portal.tsx"; -import { usePulse } from "../lib/pulse-visit.tsx"; import { isInteractiveShortcutTarget } from "../lib/queue-workflow.ts"; import { packsPending, repoHref, reposNeedingSetup, triageEnabledRepos, triageReadyRepos } from "../lib/repo-rows.ts"; import { useTriageVerdicts } from "../lib/triage-verdicts.ts"; import { upgradeLabel, usePolicyUpgrades } from "../lib/policy-upgrades.ts"; import { findPrItem } from "../lib/triage-view.ts"; -import { packOf, useCheckPacks, useRepoPack, type PackState } from "../lib/check-packs.ts"; +import { findTriageRole } from "../lib/automation-grid.ts"; +import { useRoles } from "../lib/tenant-entities.ts"; +import { checkPackQuery, packOf, useCheckPacks, useRepoPack, type PackState } from "../lib/check-packs.ts"; import { FilterMenu, FilterPills } from "../components/InboxFilter.tsx"; +import { GrantsCard, missesGrants, readGrants } from "../components/repo-panel/PolicyGrants.tsx"; import { ExternalIcon, SearchIcon } from "../components/inbox-icons.tsx"; import PullToRefresh from "../components/PullToRefresh.tsx"; import { pullUrl, viewOnHost } from "../lib/pull-link.ts"; const GROUPING_LABEL: Record = { action: "Action", repo: "Repo", waits: "Waits on", owner: "Owner" }; -const LIST_TITLE: Record = { "needs-you": "Needs you", ready: "Ready", "all-open": "All open" }; +const LIST_TITLE: Record = { "needs-you": "Needs you", ready: "Ready", "all-open": "Open", all: "All" }; const WAITING = "Waiting"; const WAITING_PARAM = "waiting"; +type Slice = { view: TriageView; waiting: boolean; label: string }; + +const SLICES: Slice[] = [ + { view: "needs-you", waiting: false, label: "Needs you" }, + { view: "needs-you", waiting: true, label: "Waiting" }, + { view: "ready", waiting: false, label: "Ready" }, + { view: "all-open", waiting: false, label: "Open" }, + { view: "all", waiting: false, label: "All" }, +]; + +function sliceTarget(params: URLSearchParams, slice: Slice): string { + const next = withTriageView(params, slice.view); + if (slice.waiting) next.set(WAITING_PARAM, "1"); + else next.delete(WAITING_PARAM); + const search = next.toString(); + return search === "" ? "/" : `/?${search}`; +} + +/** Needs you, Waiting, Ready, Open and All. Choosing Needs you leaves Waiting. */ +function TriageSliceButtons({ waitingCount }: { waitingCount: number }) { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const view = triageView(searchParams); + const waiting = view === "needs-you" && searchParams.has(WAITING_PARAM); + + function showSlice(slice: Slice) { + navigate(sliceTarget(searchParams, slice)); + } + + return ( +
+ {SLICES.map((slice) => ( + + ))} +
+ ); +} + // Narrower screens show the list or the pane, so only side by side does the first row open by itself. const SIDE_BY_SIDE = "not (max-width: 960px)"; @@ -108,22 +148,11 @@ function AuthorTag({ kind }: { kind: AuthorKind | null }) { return Author: {label}; } -function listsOpen(view: ListView): boolean { +function listsOpen(view: TriageView): boolean { return view !== "needs-you"; } -function viewEntries(view: ListView, open: OpenItem[], enabled: ReadonlySet): OpenItem[] { - switch (view) { - case "needs-you": - return needsYou(open, enabled); - case "ready": - return readyToMerge(open, enabled); - case "all-open": - return allOpen(open, enabled); - } -} - -function groupingsOf(view: ListView, listsRest: boolean): Array { +function groupingsOf(view: TriageView, listsRest: boolean): Array { if (view === "ready") return []; return listsRest ? OPEN_GROUPINGS : INBOX_GROUPINGS; } @@ -132,31 +161,34 @@ function repoName(repo: string): string { return repo.slice(repo.lastIndexOf("/") + 1); } -type RowProps = { view: ListView; entry: OpenItem; pack: CheckPack | null; byRepo: boolean; selected: boolean; search: string }; +type RowProps = { entry: OpenItem; pack: CheckPack | null; byRepo: boolean; selected: boolean; search: string }; /** Grouped by repository the pile names it, so the row names the author instead. */ -function Row({ view, entry, pack, byRepo, selected, search }: RowProps) { +function Row({ entry, pack, byRepo, selected, search }: RowProps) { const { item, handled } = entry; const status = rowStatus(item, handled, pack); - return ( - + const href = listHref(item); + const cells = ( + <> {byRepo ? null : repoName(item.repo)}{item.number === null ? null : `#${item.number}`} {titleText(item.title)} {byRepo ? {item.author} : null} {ageText(item.waitingSince)} {status.text}{status.more > 0 ? {moreText(status.more)} : null} - + ); + if (href === undefined) return
{cells}
; + return {cells}; } -type PileProps = { view: ListView; byRepo: boolean; pile: OpenPile; packs: Map; selectedKey: string | undefined; search: string }; +type PileProps = { byRepo: boolean; pile: OpenPile; packs: Map; selectedKey: string | undefined; search: string }; -function Pile({ view, byRepo, pile, packs, selectedKey, search }: PileProps) { +function Pile({ byRepo, pile, packs, selectedKey, search }: PileProps) { return ( <>
{pile.label}{pile.items.length}
- {pile.items.map((entry) => )} + {pile.items.map((entry) => )} ); } @@ -467,7 +499,7 @@ function ListEmpty({ title, text, action }: { title: string; text: string; actio return

{title}

{text}

{action}
; } -type EmptyListProps = { view: ListView; waiting: boolean; waitingCount: number; loading: boolean; denied: boolean; noRepos: boolean; filtered: boolean; onClear: () => void; onShowWaiting: () => void }; +type EmptyListProps = { view: TriageView; waiting: boolean; waitingCount: number; loading: boolean; denied: boolean; noRepos: boolean; filtered: boolean; onClear: () => void; onShowWaiting: () => void }; /** A repository whose policy file triage refuses runs nothing, so its pull requests wait here without a verdict. */ function RefusedPolicies({ repos }: { repos: string[] }) { @@ -507,7 +539,7 @@ function EmptyList({ view, waiting, waitingCount, loading, denied, noRepos, filt if (filtered) { return Clear filters} />; } - if (view === "all-open") return ; + if (view === "all" || view === "all-open") return ; if (view === "ready") return ; if (waiting) return ; const showWaiting = waitingCount > 0 ? : undefined; @@ -536,26 +568,48 @@ function Toasts({ notices, onUndo }: { notices: Notices; onUndo: () => void }) { ); } -function PulseLine() { - const { pulse } = usePulse(); - if (pulse === null) return null; - const counts = [`${pulse.since.fresh.length} new`, `${pulse.since.resolved.length} resolved`, `${pulse.stuck.length} stuck`]; - if (pulse.acted > 0) counts.push(`${pulse.acted} acted on by you`); +/** Without a decision model Triage judges nothing, so the list points to where one is connected. */ +function ConnectModel() { + const { snapshot, readOnly } = usePortal(); + if (snapshot?.decisionModel) return null; return ( - - {pulse.since.fresh.length} new