// — a card of the reports tab: mara's custom issue report: browse open issues // across every repo that has one, sortable/filterable her own way, with // "hide blocked (open dependency)" as the headline filter and a rank // column for how many other open issues depend on each one. Backed by // swarm-controller's `GET /api/repos` (dropdown source, only repos with // an open issue), `GET /api/issue-report` (default — every repo // combined) and `GET /api/repos/{org}/{repo}/issue-report` (once a // specific repo is picked) — `blocked`, `depended_on_by_count` and // `transitively_blocks_count` all arrive pre-resolved per row, so // nothing here does its own dependency-graph walk. // // Sorting and the hide-blocked/label/etc. filters are all client-side // over whatever's currently loaded — only the repo selection re-fetches. // Default sort is `depended_on_by_count` descending: mara's own framing // ("i would also like to rank them by how many issues depend on them") // reads as the report's headline ordering, not just one more column. // // No `RefreshIntervalPicker`/auto-poll like the roster pages — this // reads as a "generate a report" action rather than a live status view // (mara: a "generating report" spinner sub-5s is fine, doesn't need to // be instant), so it fetches once per repo-selection change instead of // polling. // // Sort state persists via the shared `useLocalSetting` hook (same // plumbing the theme/motion overrides use) — mara: "local storage" over // URL params. The label filter used to be this page's own bespoke // checkbox row — folded into the "labels" column's own `Table` filter // (`filterMode: "multiselect"`) per mara's "fold filters into table" // request, one mechanism instead of two. import { useEffect, useMemo, useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Badge } from "@hive/shared/badge.js"; import { useLocalSetting } from "@hive/shared/settings-storage.js"; import { LazyDisclosure } from "../ui/disclosure/LazyDisclosure.js"; import { CheckboxField } from "../ui/checkbox-field/CheckboxField.js"; import { SelectField } from "../ui/select-field/SelectField.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; import { ALL_REPOS, reportUrl, repoOptions } from "./issue-report-repo.js"; import "./IssueReportPage.css"; interface IssueReportRow { repo: string; number: number; title: string; labels: string[]; // `string[]`, not a single `assignee` — forge's own `assignee` field is // the legacy single-value one; `assignees` is the real multi-assignee // list, and this repo actually uses multiple (caught against an // earlier draft of this row shape that had it singular). assignees: string[]; html_url: string | null; blocked: boolean; depended_on_by_count: number; transitively_blocks_count: number; } type SortKey = | "repo" | "number" | "title" | "assignees" | "blocked" | "depended_on_by_count" | "transitively_blocks_count"; type SortDir = "asc" | "desc"; // One key per persisted control, namespaced like the theme/motion keys // (`swarm-ui:issue-report:…`) so nothing else on the page — or a future // page — collides with these by accident. const REPO_FILTER_KEY = "swarm-ui:issue-report:repo-filter"; const HIDE_BLOCKED_KEY = "swarm-ui:issue-report:hide-blocked"; const SORT_KEY_KEY = "swarm-ui:issue-report:sort-key"; const SORT_DIR_KEY = "swarm-ui:issue-report:sort-dir"; // `Table`'s own filter state (repo/labels/etc. are handled separately // above/below — this key is just for the generic per-column filters, // e.g. title/assignees/blocked). const TABLE_FILTERS_KEY = "swarm-ui:issue-report:table-filters"; function compareRows( a: IssueReportRow, b: IssueReportRow, key: SortKey, ): number { switch (key) { case "repo": return a.repo.localeCompare(b.repo); case "number": return a.number - b.number; case "title": return a.title.localeCompare(b.title); case "assignees": return a.assignees.join(", ").localeCompare(b.assignees.join(", ")); case "blocked": return Number(a.blocked) - Number(b.blocked); case "depended_on_by_count": return a.depended_on_by_count - b.depended_on_by_count; case "transitively_blocks_count": return a.transitively_blocks_count - b.transitively_blocks_count; } } function SortHeader({ label, sortKey, activeKey, dir, onSort, }: { label: string; sortKey: SortKey; activeKey: SortKey; dir: SortDir; onSort: (key: SortKey) => void; }) { const active = sortKey === activeKey; return ( ); } function IssueReportBody() { const [repos, setRepos] = useState(null); const [repoFilter, setRepoFilter] = useLocalSetting( REPO_FILTER_KEY, ALL_REPOS, ); const [rows, setRows] = useState(null); // One slot per request, so a report that loads does not clear a repo // list that failed. const [reposError, setReposError] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [hideBlocked, setHideBlocked] = useLocalSetting( HIDE_BLOCKED_KEY, false, ); const [sortKey, setSortKey] = useLocalSetting( SORT_KEY_KEY, "depended_on_by_count", ); const [sortDir, setSortDir] = useLocalSetting(SORT_DIR_KEY, "desc"); // Repo dropdown source, fetched once — this page has no refresh // cadence, and a repo gaining/losing its first/last open issue between // visits is rare enough that a manual reload covers it. Same // `cancelled` guard as the repo-filter effect below, so an unmount // mid-flight (navigating away before this resolves) doesn't call // `setRepos`/`setReposError` on a component that's already gone. useEffect(() => { let cancelled = false; (async () => { const r = await fetch("/api/repos"); if (!r.ok) { if (!cancelled) setReposError(await readApiError(r)); return; } const data = (await r.json()) as string[]; if (!cancelled) setRepos(data); })().catch((e: unknown) => { if (!cancelled) setReposError({ detail: String(e) }); }); return () => { cancelled = true; }; }, []); // The one fetch this page re-runs on state change — everything else // (sort, hide-blocked, the table's own per-column filters) works over // what's already loaded. useEffect(() => { let cancelled = false; setLoading(true); // A failed report drops the rows, so the table never shows another // repo's issues under this repo's error. const fail = (problem: ProblemDetails) => { setRows(null); setError(problem); }; (async () => { const r = await fetch(reportUrl(repoFilter)); if (!r.ok) { const problem = await readApiError(r); if (!cancelled) fail(problem); return; } const data = (await r.json()) as IssueReportRow[]; if (cancelled) return; setRows(data); setError(null); })() .catch((e: unknown) => { if (!cancelled) fail({ detail: String(e) }); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [repoFilter]); const visibleRows = useMemo(() => { let out = rows ?? []; if (hideBlocked) out = out.filter((r) => !r.blocked); return [...out].sort((a, b) => { const c = compareRows(a, b, sortKey); return sortDir === "asc" ? c : -c; }); }, [rows, hideBlocked, sortKey, sortDir]); function onSort(key: SortKey) { if (key === sortKey) { setSortDir(sortDir === "asc" ? "desc" : "asc"); } else { setSortKey(key); setSortDir("asc"); } } // `aria-sort` for a sortable column's `` — 'none' while sortable // but not the active column, the real direction while it is. Screen // readers announce this; the ▲/▼ glyph in `SortHeader` is `aria-hidden` // and carries no information without it. function ariaSortFor(key: SortKey): "ascending" | "descending" | "none" { if (sortKey !== key) return "none"; return sortDir === "asc" ? "ascending" : "descending"; } const columns: TableColumn[] = [ // Shown even with one repo selected (every row shares the value) — // a fixed column set means the table's shape doesn't shift under // sort/filter state, and it's a free confirmation of what's loaded. { key: "repo", header: ( ), ariaSort: ariaSortFor("repo"), render: (r) => r.repo, }, { key: "number", header: ( ), ariaSort: ariaSortFor("number"), render: (r) => r.html_url ? ( #{r.number} ) : ( `#${r.number}` ), }, { key: "title", header: ( ), ariaSort: ariaSortFor("title"), render: (r) => r.title, filterValue: (r) => r.title, }, { key: "labels", header: "labels", render: (r) => (r.labels.length ? r.labels.join(", ") : "—"), filterValues: (r) => r.labels, filterMode: "multiselect", }, { key: "assignees", header: ( ), ariaSort: ariaSortFor("assignees"), render: (r) => (r.assignees.length ? r.assignees.join(", ") : "—"), // Multiselect, same machinery the "labels" column above already // uses — `r.assignees` is already the per-row array this mode // wants, so there's no reason to fall back to a joined-string // substring search here. filterValues: (r) => r.assignees, filterMode: "multiselect", }, { key: "blocked", header: ( ), ariaSort: ariaSortFor("blocked"), render: (r) => r.blocked ? : "—", // A distinct gap from the existing "hide blocked" toggle above: // that one only *hides* blocked issues, there was no way to see // *only* the blocked ones. Synthesized two-value set (not the raw // boolean) — multiselect, same as every other enum-shaped column, // rather than the single-value `"select"` mode. filterValues: (r) => [r.blocked ? "blocked" : "not blocked"], filterMode: "multiselect", }, { key: "depended_on_by_count", header: ( ), ariaSort: ariaSortFor("depended_on_by_count"), render: (r) => r.depended_on_by_count, }, { key: "transitively_blocks_count", header: ( ), ariaSort: ariaSortFor("transitively_blocks_count"), render: (r) => r.transitively_blocks_count, }, ]; return ( <>
{reposError ? ( ) : null} {error ? ( ) : null} {!error && loading ?

generating report…

: null} {!loading && rows ? ( `${r.repo}#${r.number}`} emptyMessage="no issues match the current filters" storageKey={TABLE_FILTERS_KEY} /> ) : null} ); } // A folded card on the reports tab: the repo list and the report are read only // once it is opened. export function IssueReportCard() { return ( ); }