diff --git a/src/app/actions/ingest.ts b/src/app/actions/ingest.ts index 9fcb0bf..b5e6483 100644 --- a/src/app/actions/ingest.ts +++ b/src/app/actions/ingest.ts @@ -77,4 +77,44 @@ export async function backfillProject(input: { revalidatePath(`/admin/projects/${input.projectId}`); return { created: result.created, skipped: result.skipped }; +} + +/** + * Pull whatever is new from Forgejo for one project. + * + * Same code path as the hourly `/api/cron/reconcile`, scoped to a single + * project and gated behind the same `requireOwner` the backfill uses. Stops at + * the first fully-known page — the dashboard button is for "did anything push + * since the last reconcile?" rather than for restoring a lost history, and + * reading only what has changed keeps it interactive. + * + * The cached metrics are refreshed inside `ingestProject`, so a successful + * call here both populates Recent Work and updates the headline numbers. + */ +export async function refreshCommitsAction(input: { + projectId: string; + clientSlug: string; + projectSlug: string; +}): Promise<{ created: number; skipped: number; pagesRead: number }> { + await requireOwner(); + + const pb = await getAdminPb(); + const project = await pb + .collection("projects") + .getOne(input.projectId); + + if (!project.repo_owner || !project.repo_name) { + throw new Error("Link a repository to this project first"); + } + + if (!(await repoExists(project.repo_owner, project.repo_name))) { + throw new Error( + `Forgejo has no repository ${project.repo_owner}/${project.repo_name}, or it is not readable` + ); + } + + const result = await ingestProject(project, { stopAfterKnownPages: 1 }); + + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/dashboard`); + return result; } \ No newline at end of file diff --git a/src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx b/src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx index 4ebe17c..48509f1 100644 --- a/src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx +++ b/src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx @@ -11,6 +11,7 @@ import { MonthBars } from "@/components/dashboard/month-bars"; import { Methodology } from "@/components/dashboard/methodology"; import { RecentActivity } from "@/components/dashboard/recent-activity"; import { RecomputeButton } from "@/components/dashboard/recompute-button"; +import { RefreshCommitsButton } from "@/components/dashboard/refresh-commits-button"; import { getSession } from "@/lib/auth/session"; import { getServerPb } from "@/lib/pb/server"; import { loadBoard } from "@/lib/data/board"; @@ -53,11 +54,18 @@ export default async function DashboardPage({ {session.role === "owner" && ( - + + + + )} diff --git a/src/components/dashboard/refresh-commits-button.tsx b/src/components/dashboard/refresh-commits-button.tsx new file mode 100644 index 0000000..901f4d0 --- /dev/null +++ b/src/components/dashboard/refresh-commits-button.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { CloudDownload } from "lucide-react"; + +import { refreshCommitsAction } from "@/app/actions/ingest"; + +/** + * "Fetch whatever is new from Forgejo" — sibling to {@link RecomputeButton}. + * + * Recompute reads cached commits and re-aggregates them into the metrics + * table; this one actually talks to Forgejo. It is the same code path as the + * hourly cron, scoped to one project and stopped at the first known page so it + * stays interactive on the dashboard. + */ +export function RefreshCommitsButton({ + projectId, + clientSlug, + projectSlug, +}: { + projectId: string; + clientSlug: string; + projectSlug: string; +}) { + const [pending, startTransition] = useTransition(); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + return ( + + + {status && !error && ( + {status} + )} + {error && ( + + {error} + + )} + + ); +}