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..12b52bfa2 --- /dev/null +++ b/apps/web/src/App.dom.test.tsx @@ -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 { + 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("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([]); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index be7050cd8..45d4e1ac3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ; +function LegacyListRedirect() { + const { pathname, search } = useLocation(); + return ; +} + +function TriageRedirect() { + return ; } function NotConfigured() { @@ -145,37 +150,38 @@ 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..f7d6040d0 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"; + // Only settings and the pull request page sit in the stage card; every other page draws its own inset panels. + const ownPanels = room !== "settings" && room !== "pr"; 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..91401cc6c 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" }; +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 || !found.decide || 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..2d47392ad 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. */ +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; +} + +/** 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..9a474af33 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] }); @@ -265,13 +242,17 @@ export function useQueueLoadingPulse(): boolean { } export function openItems(items: PrItem[], handled: Handled, held: Handled): OpenItem[] { + return knownItems(items, handled, held).filter((open) => !LEAVING.has(open.handled?.kind ?? null)); +} + +/** 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) })) - .filter((open) => !LEAVING.has(open.handled?.kind ?? null)); + .map((item) => ({ item, handled: handledMark(held, item) ?? handledMark(handled, item) })); } -export function useOpenItems(): OpenItem[] { +export function useMarkedItems(): { open: OpenItem[]; known: OpenItem[] } { const { snapshot } = usePortal(); const tenantId = snapshot?.workspace.tenantId; const items = usePullRequestItems(); @@ -279,11 +260,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..0532cac40 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,74 @@ 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, view: TriageView, waiting: boolean): string { + const next = withTriageView(params, view); + if (waiting) next.set(WAITING_PARAM, "1"); + const search = next.toString(); + return search === "" ? "/" : `/?${search}`; +} + +type SliceButtonsProps = { view: TriageView; waiting: boolean; waitingCount: number; onShow: (view: TriageView, waiting: boolean) => void }; + +function SliceButtons({ view, waiting, waitingCount, onShow }: SliceButtonsProps) { + 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 +139,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 +152,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 +490,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 +530,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 +559,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