From fc1fa41a28cb868bd0a31b25492999474a033d95 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 24 May 2026 21:36:00 -0500 Subject: [PATCH] health stats for Open Issues and Webhook coverage --- .../src/app/settings/git-projects/page.tsx | 520 +++++++++++++++--- .../git/ForgejoRepositoriesTable.tsx | 153 +++++- 2 files changed, 577 insertions(+), 96 deletions(-) diff --git a/frontend/src/app/settings/git-projects/page.tsx b/frontend/src/app/settings/git-projects/page.tsx index 74425fa..780081f 100644 --- a/frontend/src/app/settings/git-projects/page.tsx +++ b/frontend/src/app/settings/git-projects/page.tsx @@ -16,6 +16,7 @@ import { GitBranch, KeyRound, Link2, + ListChecks, Loader2, RefreshCw, Server, @@ -39,6 +40,7 @@ import { deleteForgejoConnection, deleteForgejoRepository, getForgejoConnections, + getLinkedBoardsForRepository, getForgejoRepositories, massImportRepositories, syncRepository, @@ -58,6 +60,26 @@ type DeleteTarget = | { type: "connection"; item: ForgejoConnection } | { type: "repository"; item: ForgejoRepository }; +type BoardLink = { + id: string; + name: string; +}; + +type LastMassImport = { + finishedAt: string; + result: MassImportResponse; +}; + +type AttentionItem = { + id: string; + title: string; + detail: string; + tone: "warning" | "danger" | "muted"; + href?: string; +}; + +const LAST_MASS_IMPORT_STORAGE_KEY = "pipeline.gitProjects.lastMassImport"; + const repositoryName = (repository: ForgejoRepository) => repository.display_name || `${repository.owner}/${repository.repo}`; @@ -68,6 +90,24 @@ const formatTimestamp = (value: string | null) => { return date.toLocaleString(); }; +const formatCompactNumber = (value: number) => + new Intl.NumberFormat(undefined, { notation: "compact" }).format(value); + +const summarizeMassImport = (result: MassImportResponse) => + `${result.total_created} created, ${result.total_updated} updated, ${result.total_stale_closed} closed`; + +const isLastMassImport = (value: unknown): value is LastMassImport => { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.finishedAt === "string" && + typeof candidate.result?.total_created === "number" && + typeof candidate.result?.total_updated === "number" && + typeof candidate.result?.total_stale_closed === "number" && + Array.isArray(candidate.result?.results) + ); +}; + function NoticeBanner({ notice }: { notice: Notice }) { return (
+
+
+ {icon} +
+
+

{title}

+

{description}

+
+
+ {action ?
{action}
: null} +
+ ); +} + function StatCard({ icon, label, @@ -118,6 +185,140 @@ function StatCard({ ); } +function AttentionPanel({ items }: { items: AttentionItem[] }) { + return ( +
+ } + title="Needs Attention" + description="Connection and repository signals that can block fresh issue data." + /> + {items.length === 0 ? ( +
+ +
+

No action needed

+

+ No sync errors, missing tokens, archived repos, or webhook gaps + need attention. +

+
+
+ ) : ( +
+ {items.map((item) => ( +
+
+

{item.title}

+

{item.detail}

+
+
+ + {item.tone} + + {item.href ? ( + + + + ) : null} +
+
+ ))} +
+ )} +
+ ); +} + +function LastImportPanel({ + lastImport, + onOpen, +}: { + lastImport: LastMassImport | null; + onOpen: () => void; +}) { + return ( +
+ } + title="Full Import" + description="Run a complete issue pull when webhooks or scheduled sync need a reset." + action={ + + } + /> +
+ {lastImport ? ( +
+
+

+ {summarizeMassImport(lastImport.result)} +

+

+ Finished {formatTimestamp(lastImport.finishedAt)} across{" "} + {lastImport.result.succeeded} repositor + {lastImport.result.succeeded === 1 ? "y" : "ies"}. + {lastImport.result.failed > 0 ? ( + + {lastImport.result.failed} failed. + + ) : null} +

+
+
+
+

+ {lastImport.result.total_created} +

+

+ Created +

+
+
+

+ {lastImport.result.total_updated} +

+

+ Updated +

+
+
+

+ {lastImport.result.total_stale_closed} +

+

+ Closed +

+
+
+
+ ) : ( +

+ No full import has run in this session. Scheduled sync still keeps + active repositories current. +

+ )} +
+
+ ); +} + function CopyButton({ value, label }: { value: string; label?: string }) { const [copied, setCopied] = useState(false); @@ -160,6 +361,12 @@ export default function GitProjectSettingsPage() { const [massImportOpen, setMassImportOpen] = useState(false); const [massImportResult, setMassImportResult] = useState(null); + const [lastMassImport, setLastMassImport] = useState( + null, + ); + const [linkedBoardsByRepository, setLinkedBoardsByRepository] = useState< + Record + >({}); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -173,6 +380,32 @@ export default function GitProjectSettingsPage() { return () => clearTimeout(id); }, [notice]); + useEffect(() => { + try { + const stored = window.localStorage.getItem(LAST_MASS_IMPORT_STORAGE_KEY); + if (!stored) return; + const parsed: unknown = JSON.parse(stored); + if (isLastMassImport(parsed)) { + setLastMassImport(parsed); + } + } catch { + // Local storage is optional for this summary. + } + }, []); + + useEffect(() => { + try { + if (lastMassImport) { + window.localStorage.setItem( + LAST_MASS_IMPORT_STORAGE_KEY, + JSON.stringify(lastMassImport), + ); + } + } catch { + // Local storage is optional for this summary. + } + }, [lastMassImport]); + const loadSettings = useCallback(async () => { try { setIsLoading(true); @@ -182,6 +415,25 @@ export default function GitProjectSettingsPage() { ]); setConnections(connectionsData); setRepositories(repositoriesData); + const boardResults = await Promise.allSettled( + repositoriesData.map(async (repo) => { + const boards = await getLinkedBoardsForRepository(repo.id); + return [repo.id, boards] as const; + }), + ); + setLinkedBoardsByRepository( + Object.fromEntries( + boardResults + .filter( + ( + result, + ): result is PromiseFulfilledResult< + readonly [string, BoardLink[]] + > => result.status === "fulfilled", + ) + .map((result) => result.value), + ), + ); setError(null); } catch (err) { setError( @@ -223,6 +475,88 @@ export default function GitProjectSettingsPage() { () => repositories.filter((r) => r.last_sync_error).length, [repositories], ); + const totalOpenIssues = useMemo( + () => + repositories.reduce((total, repo) => total + repo.open_issues_count, 0), + [repositories], + ); + const activeRepositoriesWithWebhooks = useMemo( + () => + repositories.filter((r) => r.active && r.has_webhook_secret).length, + [repositories], + ); + const archivedRepositories = useMemo( + () => repositories.filter((r) => r.is_archived).length, + [repositories], + ); + const attentionItems = useMemo(() => { + const connectionItems = connections.flatMap((connection) => { + const items: AttentionItem[] = []; + if (!connection.active) { + items.push({ + id: `connection-inactive-${connection.id}`, + title: `${connection.name} is inactive`, + detail: "Repositories using this connection will not receive fresh issue data.", + tone: "muted", + href: `/git-projects/connections/${connection.id}/edit`, + }); + } + if (!connection.has_token) { + items.push({ + id: `connection-token-${connection.id}`, + title: `${connection.name} is missing a token`, + detail: "Validation, sync, and imports need a configured provider token.", + tone: "danger", + href: `/git-projects/connections/${connection.id}/edit`, + }); + } + return items; + }); + + const repositoryItems = repositories.flatMap((repo) => { + const name = repositoryName(repo); + const items: AttentionItem[] = []; + if (repo.last_sync_error) { + items.push({ + id: `repo-error-${repo.id}`, + title: `${name} has a sync error`, + detail: repo.last_sync_error, + tone: "danger", + href: `/git-projects/repositories/${repo.id}/edit`, + }); + } + if (repo.active && !repo.has_webhook_secret) { + items.push({ + id: `repo-webhook-${repo.id}`, + title: `${name} has no webhook secret`, + detail: "Webhook setup can receive events, but signature validation needs a stored secret.", + tone: "warning", + href: `/git-projects/repositories/${repo.id}/edit`, + }); + } + if (repo.is_archived) { + items.push({ + id: `repo-archived-${repo.id}`, + title: `${name} is archived upstream`, + detail: "Archived repositories can stay visible, but they may not need active syncing.", + tone: "muted", + href: `/git-projects/repositories/${repo.id}/edit`, + }); + } + if (!repo.active) { + items.push({ + id: `repo-inactive-${repo.id}`, + title: `${name} is inactive`, + detail: "Inactive repositories are excluded from Sync All and full imports.", + tone: "muted", + href: `/git-projects/repositories/${repo.id}/edit`, + }); + } + return items; + }); + + return [...connectionItems, ...repositoryItems]; + }, [connections, repositories]); const webhookBaseUrl = useMemo(() => { try { @@ -310,6 +644,10 @@ export default function GitProjectSettingsPage() { try { const result = await massImportRepositories(); setMassImportResult(result); + setLastMassImport({ + finishedAt: new Date().toISOString(), + result, + }); await loadSettings(); } catch (err) { setNotice({ @@ -431,7 +769,6 @@ export default function GitProjectSettingsPage() { variant="outline" size="sm" onClick={() => { - setMassImportResult(null); setMassImportOpen(true); }} disabled={isMassImporting || isLoading || activeRepositories === 0} @@ -465,7 +802,7 @@ export default function GitProjectSettingsPage() { ) : null} {/* Stats */} -
+
} label="Connections" @@ -478,6 +815,18 @@ export default function GitProjectSettingsPage() { value={`${activeRepositories}/${repositories.length}`} caption="Active tracked repositories." /> + } + label="Open Issues" + value={formatCompactNumber(totalOpenIssues)} + caption="Open issues reported by tracked repositories." + /> + } + label="Webhooks" + value={`${activeRepositoriesWithWebhooks}/${activeRepositories}`} + caption="Active repositories with webhook secrets." + /> } label="Latest Sync" @@ -488,28 +837,33 @@ export default function GitProjectSettingsPage() { icon={} label="Sync Errors" value={String(syncErrorCount)} - caption="Repositories with a recorded sync error." + caption={`Repositories with sync errors; ${archivedRepositories} archived tracked.`} />
+
+ + setMassImportOpen(true)} + /> +
+ {/* Connections */}
-
-
-

- Forgejo Connections -

-

- URL and token records used by tracked repositories. -

-
- - - -
+ } + title="Forgejo Connections" + description="URL and token records used by tracked repositories." + action={ + + + + } + />
-
-
-

- Tracked Repositories -

-

- Repositories whose issues are cached and shown in Pipeline. -

-
-
- - - - - - -
-
+ } + title="Tracked Repositories" + description="Repositories whose issues are cached and shown in Pipeline." + action={ +
+ + + + + + +
+ } + />
setDeleteTarget({ type: "repository", item: repository }) } @@ -563,28 +915,19 @@ export default function GitProjectSettingsPage() { {/* Webhook Setup */}
-
-
- -
-
-

- Webhook Setup -

-

- Configure webhooks in Forgejo to push issue updates to - Pipeline in real time, without waiting for the scheduled sync. -

-
-
+ } + title="Webhook Setup" + description="Configure Forgejo webhooks to push issue updates to Pipeline in real time." + /> {repositories.length === 0 ? ( -

+

No repositories tracked yet. Add a repository to see webhook URLs.

) : ( -
+

In each Forgejo repository, go to{" "} @@ -673,35 +1016,30 @@ export default function GitProjectSettingsPage() { {/* Scheduled Sync */}

-
-
- -
-
-

- Scheduled Sync -

-

- Pipeline runs a background sync for all active repositories - every 60 minutes. - This keeps issues current without manual syncing or webhooks. - The interval is configured via environment variable and cannot - be changed from the UI. -

-
- - Env:{" "} - - FORGEJO_SYNC_ENABLED - - - - Env:{" "} - - FORGEJO_SYNC_INTERVAL_SECONDS - - -
+ } + title="Scheduled Sync" + description="Pipeline runs a background sync for all active repositories every 60 minutes." + /> +
+

+ This keeps issues current without manual syncing or webhooks. + The interval is configured via environment variable and cannot + be changed from the UI. +

+
+ + Env:{" "} + + FORGEJO_SYNC_ENABLED + + + + Env:{" "} + + FORGEJO_SYNC_INTERVAL_SECONDS + +
@@ -826,7 +1164,11 @@ export default function GitProjectSettingsPage() {
-
+
+
); }, @@ -132,11 +156,126 @@ const columns = ( accessorKey: "status", header: "Status", cell: ({ row }) => { - const isActive = row.original.active; + const repo = row.original; + const isActive = repo.active; return ( - - {isActive ? "Active" : "Inactive"} - +
+ + {isActive ? "Active" : "Inactive"} + + {repo.is_archived ? ( + + + Archived + + ) : null} + {repo.has_webhook_secret ? ( + + + Webhook + + ) : ( + + + No secret + + )} +
+ ); + }, + }, + { + accessorKey: "openIssues", + header: "Issues", + cell: ({ row }) => ( +
+ + {row.original.open_issues_count} + + open upstream +
+ ), + }, + { + accessorKey: "metadata", + header: "Metadata", + cell: ({ row }) => { + const repo = row.original; + const visibleTopics = repo.topics.slice(0, 2); + const hiddenTopics = Math.max( + repo.topics.length - visibleTopics.length, + 0, + ); + const visibleLabels = repo.labels.slice(0, 2); + const hiddenLabels = Math.max( + repo.labels.length - visibleLabels.length, + 0, + ); + + return ( +
+ + default: {repo.default_branch || "unknown"} + +
+ {visibleTopics.map((topic) => ( + + + {topic} + + ))} + {hiddenTopics > 0 ? ( + +{hiddenTopics} topics + ) : null} + {visibleTopics.length === 0 ? ( + No topics + ) : null} +
+
+ {visibleLabels.map((label) => ( + + + {label.name} + + ))} + {hiddenLabels > 0 ? ( + +{hiddenLabels} labels + ) : null} +
+
+ ); + }, + }, + { + accessorKey: "boards", + header: "Boards", + cell: ({ row }) => { + const boards = linkedBoardsByRepository[row.original.id] ?? []; + const visibleBoards = boards.slice(0, 3); + const hiddenBoards = Math.max(boards.length - visibleBoards.length, 0); + + if (boards.length === 0) { + return No linked boards; + } + + return ( +
+ {visibleBoards.map((board) => ( + + {board.name} + + ))} + {hiddenBoards > 0 ? ( + +{hiddenBoards} + ) : null} +
); }, },