Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
432 lines
13 KiB
TypeScript
432 lines
13 KiB
TypeScript
// <IssueReportPage> — 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 label/hide-blocked filters are 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/filter state persists via the shared `useLocalSetting` hook (same
|
|
// plumbing the theme/motion overrides use) — mara: "local storage" over
|
|
// URL params. `labelFilter` is `string[]`, not `Set<string>`: a `Set`
|
|
// serializes to `"{}"` through `JSON.stringify` and silently loses its
|
|
// contents, which is exactly what this hook round-trips through.
|
|
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 { Panel } from "../ui/panel/Panel.js";
|
|
import { SelectField } from "../ui/select-field/SelectField.js";
|
|
import { Table, type TableColumn } from "../ui/table/Table.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";
|
|
|
|
// Sentinel `<select>` value for "every repo" — `''` can't collide with a
|
|
// real `owner/name` value, which always contains a slash.
|
|
const ALL_REPOS = "";
|
|
|
|
// 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 LABEL_FILTER_KEY = "swarm-ui:issue-report:label-filter";
|
|
const SORT_KEY_KEY = "swarm-ui:issue-report:sort-key";
|
|
const SORT_DIR_KEY = "swarm-ui:issue-report:sort-dir";
|
|
|
|
function splitRepo(repo: string): { org: string; name: string } | null {
|
|
const i = repo.indexOf("/");
|
|
if (i < 0) return null;
|
|
return { org: repo.slice(0, i), name: repo.slice(i + 1) };
|
|
}
|
|
|
|
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 (
|
|
<button
|
|
type="button"
|
|
class="issue-report-sort-btn"
|
|
onClick={() => onSort(sortKey)}
|
|
>
|
|
{label}
|
|
{active ? (
|
|
<span aria-hidden="true"> {dir === "asc" ? "▲" : "▼"}</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function IssueReportPage() {
|
|
const [repos, setRepos] = useState<string[] | null>(null);
|
|
const [repoFilter, setRepoFilter] = useLocalSetting(
|
|
REPO_FILTER_KEY,
|
|
ALL_REPOS,
|
|
);
|
|
const [rows, setRows] = useState<IssueReportRow[] | null>(null);
|
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [hideBlocked, setHideBlocked] = useLocalSetting(
|
|
HIDE_BLOCKED_KEY,
|
|
false,
|
|
);
|
|
const [labelFilter, setLabelFilter] = useLocalSetting<string[]>(
|
|
LABEL_FILTER_KEY,
|
|
[],
|
|
);
|
|
const [sortKey, setSortKey] = useLocalSetting<SortKey>(
|
|
SORT_KEY_KEY,
|
|
"depended_on_by_count",
|
|
);
|
|
const [sortDir, setSortDir] = useLocalSetting<SortDir>(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`/`setError` on a component that's already gone.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
const r = await fetch("/api/repos");
|
|
if (!r.ok) {
|
|
if (!cancelled) setError(await readApiError(r));
|
|
return;
|
|
}
|
|
const data = (await r.json()) as string[];
|
|
if (!cancelled) setRepos(data);
|
|
})().catch((e: unknown) => {
|
|
if (!cancelled) setError({ detail: String(e) });
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
// The one fetch this page re-runs on state change — everything else
|
|
// (sort, hide-blocked, label filter) works over what's already loaded.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
const split = repoFilter ? splitRepo(repoFilter) : null;
|
|
const url = split
|
|
? `/api/repos/${split.org}/${split.name}/issue-report`
|
|
: "/api/issue-report";
|
|
(async () => {
|
|
const r = await fetch(url);
|
|
if (!r.ok) {
|
|
if (!cancelled) setError(await readApiError(r));
|
|
return;
|
|
}
|
|
const data = (await r.json()) as IssueReportRow[];
|
|
if (cancelled) return;
|
|
setRows(data);
|
|
setError(null);
|
|
})()
|
|
.catch((e: unknown) => {
|
|
if (!cancelled) setError({ detail: String(e) });
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [repoFilter]);
|
|
|
|
// Label choices are derived from whatever's currently loaded, not a
|
|
// separate endpoint — naturally scoped to "all repos" or one repo
|
|
// depending on the current selection, and never asks for a label that
|
|
// isn't actually present in the report.
|
|
const allLabels = useMemo(() => {
|
|
const s = new Set<string>();
|
|
for (const row of rows ?? []) for (const l of row.labels) s.add(l);
|
|
return [...s].sort();
|
|
}, [rows]);
|
|
|
|
const visibleRows = useMemo(() => {
|
|
let out = rows ?? [];
|
|
if (hideBlocked) out = out.filter((r) => !r.blocked);
|
|
if (labelFilter.length > 0)
|
|
out = out.filter((r) => r.labels.some((l) => labelFilter.includes(l)));
|
|
return [...out].sort((a, b) => {
|
|
const c = compareRows(a, b, sortKey);
|
|
return sortDir === "asc" ? c : -c;
|
|
});
|
|
}, [rows, hideBlocked, labelFilter, sortKey, sortDir]);
|
|
|
|
function onSort(key: SortKey) {
|
|
if (key === sortKey) {
|
|
setSortDir(sortDir === "asc" ? "desc" : "asc");
|
|
} else {
|
|
setSortKey(key);
|
|
setSortDir("asc");
|
|
}
|
|
}
|
|
|
|
function toggleLabel(label: string) {
|
|
setLabelFilter(
|
|
labelFilter.includes(label)
|
|
? labelFilter.filter((l) => l !== label)
|
|
: [...labelFilter, label],
|
|
);
|
|
}
|
|
|
|
// `aria-sort` for a sortable column's `<th>` — '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<IssueReportRow>[] = [
|
|
// 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: (
|
|
<SortHeader
|
|
label="repo"
|
|
sortKey="repo"
|
|
activeKey={sortKey}
|
|
dir={sortDir}
|
|
onSort={onSort}
|
|
/>
|
|
),
|
|
ariaSort: ariaSortFor("repo"),
|
|
render: (r) => r.repo,
|
|
},
|
|
{
|
|
key: "number",
|
|
header: (
|
|
<SortHeader
|
|
label="issue"
|
|
sortKey="number"
|
|
activeKey={sortKey}
|
|
dir={sortDir}
|
|
onSort={onSort}
|
|
/>
|
|
),
|
|
ariaSort: ariaSortFor("number"),
|
|
render: (r) =>
|
|
r.html_url ? (
|
|
<a href={r.html_url} target="_blank" rel="noreferrer">
|
|
#{r.number}
|
|
</a>
|
|
) : (
|
|
`#${r.number}`
|
|
),
|
|
},
|
|
{
|
|
key: "title",
|
|
header: (
|
|
<SortHeader
|
|
label="title"
|
|
sortKey="title"
|
|
activeKey={sortKey}
|
|
dir={sortDir}
|
|
onSort={onSort}
|
|
/>
|
|
),
|
|
ariaSort: ariaSortFor("title"),
|
|
render: (r) => r.title,
|
|
},
|
|
{
|
|
key: "labels",
|
|
header: "labels",
|
|
render: (r) => (r.labels.length ? r.labels.join(", ") : "—"),
|
|
},
|
|
{
|
|
key: "assignees",
|
|
header: (
|
|
<SortHeader
|
|
label="assignees"
|
|
sortKey="assignees"
|
|
activeKey={sortKey}
|
|
dir={sortDir}
|
|
onSort={onSort}
|
|
/>
|
|
),
|
|
ariaSort: ariaSortFor("assignees"),
|
|
render: (r) => (r.assignees.length ? r.assignees.join(", ") : "—"),
|
|
},
|
|
{
|
|
key: "blocked",
|
|
header: (
|
|
<SortHeader
|
|
label="blocked"
|
|
sortKey="blocked"
|
|
activeKey={sortKey}
|
|
dir={sortDir}
|
|
onSort={onSort}
|
|
/>
|
|
),
|
|
ariaSort: ariaSortFor("blocked"),
|
|
render: (r) =>
|
|
r.blocked ? <Badge tone="warning" value="blocked" /> : "—",
|
|
},
|
|
{
|
|
key: "depended_on_by_count",
|
|
header: (
|
|
<SortHeader
|
|
label="depended on by"
|
|
sortKey="depended_on_by_count"
|
|
activeKey={sortKey}
|
|
dir={sortDir}
|
|
onSort={onSort}
|
|
/>
|
|
),
|
|
ariaSort: ariaSortFor("depended_on_by_count"),
|
|
render: (r) => r.depended_on_by_count,
|
|
},
|
|
{
|
|
key: "transitively_blocks_count",
|
|
header: (
|
|
<SortHeader
|
|
label="transitively blocks"
|
|
sortKey="transitively_blocks_count"
|
|
activeKey={sortKey}
|
|
dir={sortDir}
|
|
onSort={onSort}
|
|
/>
|
|
),
|
|
ariaSort: ariaSortFor("transitively_blocks_count"),
|
|
render: (r) => r.transitively_blocks_count,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Panel title="issue report" icon="📋">
|
|
<div class="issue-report-controls">
|
|
<SelectField
|
|
id="issue-report-repo"
|
|
label="repo"
|
|
value={repoFilter}
|
|
onChange={setRepoFilter}
|
|
options={[
|
|
{ value: ALL_REPOS, label: "all repos" },
|
|
...(repos ?? []).map((r) => ({ value: r, label: r })),
|
|
]}
|
|
/>
|
|
<label class="issue-report-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={hideBlocked}
|
|
onChange={(e) =>
|
|
setHideBlocked((e.target as HTMLInputElement).checked)
|
|
}
|
|
/>
|
|
hide blocked (open dependency)
|
|
</label>
|
|
</div>
|
|
{allLabels.length ? (
|
|
<div class="issue-report-labels">
|
|
{allLabels.map((l) => (
|
|
<label key={l} class="issue-report-label-chip">
|
|
<input
|
|
type="checkbox"
|
|
checked={labelFilter.includes(l)}
|
|
onChange={() => toggleLabel(l)}
|
|
/>
|
|
{l}
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{error ? (
|
|
<ApiErrorPanel
|
|
context="failed to load the issue report"
|
|
problem={error}
|
|
/>
|
|
) : null}
|
|
{!error && loading ? <p>generating report…</p> : null}
|
|
{!loading && rows ? (
|
|
<Table
|
|
columns={columns}
|
|
rows={visibleRows}
|
|
rowKey={(r) => `${r.repo}#${r.number}`}
|
|
emptyMessage="no issues match the current filters"
|
|
/>
|
|
) : null}
|
|
</Panel>
|
|
);
|
|
}
|