diff --git a/apps/web/src/components/app/release-admin-banners.tsx b/apps/web/src/components/app/release-admin-banners.tsx new file mode 100644 index 00000000..a9ad2d8c --- /dev/null +++ b/apps/web/src/components/app/release-admin-banners.tsx @@ -0,0 +1,91 @@ +import { CheckCircle2, XCircle } from "lucide-react"; +import { cleanError } from "@/components/app/release-utils"; +import { ActivityBars } from "@/components/ui/activity-bars"; +import { Button } from "@/components/ui/button"; +import type { ReleaseJob } from "@/hooks/use-release-stream"; + +type ReleaseJobBannersProps = { + job: ReleaseJob; + releaseInFlight: boolean; + isFailed: boolean; + isDone: boolean; + watchedRelease: boolean; + onShowProgress: () => void; + onDismiss: () => void; +}; + +/** + * In-flight / failed / finished banners for a release-create job. The page + * stays usable behind them — each banner just links into the progress view. + */ +export function ReleaseJobBanners({ + job, + releaseInFlight, + isFailed, + isDone, + watchedRelease, + onShowProgress, + onDismiss, +}: ReleaseJobBannersProps): JSX.Element { + return ( + <> + {releaseInFlight && ( +
+ + + Releasing{" "} + {job.versionType} + {job.tag && ( + <> + {" "} + {job.tag} + + )}{" "} + — {job.phase} + + +
+ )} + {isFailed && ( +
+ + + Release failed + {job.error ? `: ${cleanError(job.error)}` : ""} + +
+ + +
+
+ )} + {isDone && watchedRelease && ( +
+ + + Released {job.tag} + + +
+ )} + + ); +} diff --git a/apps/web/src/components/app/release-admin-create.tsx b/apps/web/src/components/app/release-admin-create.tsx new file mode 100644 index 00000000..cef07d7e --- /dev/null +++ b/apps/web/src/components/app/release-admin-create.tsx @@ -0,0 +1,136 @@ +import { ShieldCheck, Sparkles, Zap } from "lucide-react"; +import { bumpVersion } from "@/components/app/release-utils"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { + ReleaseInfo, + ReleaseVersionType, +} from "@/hooks/use-release-stream"; +import { cn } from "@/lib/utils"; + +const VERSION_CONFIG: Record< + ReleaseVersionType, + { icon: typeof ShieldCheck; color: string } +> = { + patch: { icon: ShieldCheck, color: "text-status-working" }, + minor: { icon: Sparkles, color: "text-status-done" }, + major: { icon: Zap, color: "text-violet-400" }, +}; + +type CreateReleaseSectionProps = { + info: ReleaseInfo | null; + bumpBase: string | null; + releaseError: string | null; + releaseInFlight: boolean; + confirmType: ReleaseVersionType | null; + onConfirmTypeChange: (type: ReleaseVersionType | null) => void; + onRelease: (versionType: ReleaseVersionType) => void; +}; + +/** + * The "Create release" section — one button per version bump plus the + * confirmation dialog. Hidden entirely when there is nothing to release. + */ +export function CreateReleaseSection({ + info, + bumpBase, + releaseError, + releaseInFlight, + confirmType, + onConfirmTypeChange, + onRelease, +}: CreateReleaseSectionProps): JSX.Element | null { + if ( + !info || + !( + info.refMissing || + Boolean(info.unreleasedFetchError) || + info.unreleasedCount > 0 + ) + ) { + return null; + } + + return ( +
+
+ Create release +
+ + {releaseError && ( +
+ {releaseError} +
+ )} + +
+ {(["patch", "minor", "major"] as ReleaseVersionType[]).map((type) => { + const { icon: Icon, color } = VERSION_CONFIG[type]; + const nextTag = bumpVersion(bumpBase, type); + return ( + + ); + })} +
+ + { + if (!open) onConfirmTypeChange(null); + }} + > + + + + Create {confirmType ?? ""} release + {confirmType && bumpVersion(bumpBase, confirmType) && ( + <> + {" "} + + {bumpVersion(bumpBase, confirmType)} + + + )} + + + This triggers the release workflow against{" "} + main on GitHub. + + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/app/release-admin-list.tsx b/apps/web/src/components/app/release-admin-list.tsx new file mode 100644 index 00000000..d2eff386 --- /dev/null +++ b/apps/web/src/components/app/release-admin-list.tsx @@ -0,0 +1,131 @@ +import { CheckCircle2, ExternalLink } from "lucide-react"; +import type { GitHubRelease } from "@/components/app/release-utils"; +import { ActivityBars } from "@/components/ui/activity-bars"; +import { Button } from "@/components/ui/button"; +import { formatShortDateTime } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +type RecentReleasesProps = { + releases: GitHubRelease[]; + releasesLoading: boolean; + promoteError: string | null; + promotingTag: string | null; + confirmPromoteTag: string | null; + onConfirmPromoteTagChange: (tag: string | null) => void; + onPromote: (tag: string) => void; +}; + +/** + * The "Recent releases" list, with the inline promote-to-stable confirm + * flow for prereleases. + */ +export function RecentReleases({ + releases, + releasesLoading, + promoteError, + promotingTag, + confirmPromoteTag, + onConfirmPromoteTagChange, + onPromote, +}: RecentReleasesProps): JSX.Element { + return ( +
+
+ Recent releases +
+ + {promoteError && ( +
+ {promoteError} +
+ )} + + {releasesLoading && releases.length === 0 && ( +
+ + Loading... +
+ )} + + {releases.length > 0 && ( +
+ {releases.map((r) => ( +
+ + {r.tag} + + + {r.isPrerelease ? "pre-release" : "stable"} + + + {formatShortDateTime(r.publishedAt)} + +
+ {r.isPrerelease && confirmPromoteTag !== r.tag && ( + + )} + {r.isPrerelease && confirmPromoteTag === r.tag && ( + <> + + Mark stable + latest? + + + + + )} + {!r.isPrerelease && ( + + )} + + + +
+
+ ))} +
+ )} + + {!releasesLoading && releases.length === 0 && ( +
No releases found
+ )} +
+ ); +} diff --git a/apps/web/src/components/app/release-admin-unreleased.tsx b/apps/web/src/components/app/release-admin-unreleased.tsx new file mode 100644 index 00000000..4db7cdd7 --- /dev/null +++ b/apps/web/src/components/app/release-admin-unreleased.tsx @@ -0,0 +1,121 @@ +import { RefreshCw } from "lucide-react"; +import { cleanError, formatAgo } from "@/components/app/release-utils"; +import { ActivityBars } from "@/components/ui/activity-bars"; +import { Button } from "@/components/ui/button"; +import type { ReleaseInfo, ReleaseProgress } from "@/hooks/use-release-stream"; +import { cn } from "@/lib/utils"; + +type UnreleasedChangesProps = { + info: ReleaseInfo | null; + infoLoading: boolean; + infoError: string | null; + infoProgress: ReleaseProgress | null; + lastCheckedAt: number | null; + now: number; + onRefresh: () => void; +}; + +/** The "Unreleased changes" section: commit count, commit list, and errors. */ +export function UnreleasedChanges({ + info, + infoLoading, + infoError, + infoProgress, + lastCheckedAt, + now, + onRefresh, +}: UnreleasedChangesProps): JSX.Element { + return ( +
+
+
+ Unreleased changes +
+
+ {lastCheckedAt !== null && !infoLoading && ( + + Checked {formatAgo(lastCheckedAt, now)} + + )} + +
+
+ + {infoLoading && ( +
+ + {infoProgress?.label ?? "Loading..."} + {infoProgress?.detail && ( + + {infoProgress.detail} + + )} +
+ )} + + {infoError && ( +
+ {infoError} +
+ )} + + {info && !infoLoading && ( + <> + {info.unreleasedFetchError ? ( +
+ Couldn't refresh origin/main — + unreleased commit info is unavailable, not necessarily zero. +
+ {cleanError(info.unreleasedFetchError)} +
+
+ ) : info.refMissing ? ( +
+ Deployed version{" "} + {info.currentTag ?? "unknown"}{" "} + not found in origin — commit count unavailable. +
+ ) : info.unreleasedCount === 0 ? ( +
+ No unreleased commits on main +
+ ) : ( +
+
+ {info.unreleasedCount} unreleased{" "} + {info.unreleasedCount === 1 ? "commit" : "commits"} on{" "} + main +
+
+ {info.commits.map((c) => ( +
+ + {c.sha} + + {c.subject} +
+ ))} + {info.unreleasedCount > info.commits.length && ( +
+ +{info.unreleasedCount - info.commits.length} more +
+ )} +
+
+ )} + + )} +
+ ); +} diff --git a/apps/web/src/components/app/release-admin.tsx b/apps/web/src/components/app/release-admin.tsx index 1e1764c6..ce8f1197 100644 --- a/apps/web/src/components/app/release-admin.tsx +++ b/apps/web/src/components/app/release-admin.tsx @@ -1,81 +1,15 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { - ArrowLeft, - CheckCircle2, - ExternalLink, - RefreshCw, - ShieldCheck, - Sparkles, - XCircle, - Zap, -} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { ArrowLeft } from "lucide-react"; +import { ReleaseJobBanners } from "@/components/app/release-admin-banners"; +import { CreateReleaseSection } from "@/components/app/release-admin-create"; +import { RecentReleases } from "@/components/app/release-admin-list"; +import { UnreleasedChanges } from "@/components/app/release-admin-unreleased"; import { OperationTakeover } from "@/components/app/release-operation-takeover"; -import { cleanError } from "@/components/app/release-utils"; -import { formatShortDateTime } from "@/lib/format"; -import { ActivityBars } from "@/components/ui/activity-bars"; +import { CREATE_PHASES, cleanError } from "@/components/app/release-utils"; +import { useReleaseAdminData } from "@/components/app/use-release-admin-data"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import type { - ReleaseInfo, - ReleaseVersionType, -} from "@/hooks/use-release-stream"; +import type { ReleaseVersionType } from "@/hooks/use-release-stream"; import { useReleaseStream } from "@/hooks/use-release-stream"; -import { cn } from "@/lib/utils"; - -type GitHubRelease = { - tag: string; - publishedAt: string; - isPrerelease: boolean; - url: string; -}; - -const VERSION_CONFIG: Record< - ReleaseVersionType, - { icon: typeof ShieldCheck; color: string } -> = { - patch: { icon: ShieldCheck, color: "text-status-working" }, - minor: { icon: Sparkles, color: "text-status-done" }, - major: { icon: Zap, color: "text-violet-400" }, -}; - -const CREATE_PHASES = ["preflight", "triggering", "watching", "done"] as const; - -/** - * Predict the tag the release workflow will cut for a version bump. - * Exact stable tags only — the workflow bumps from main's package - * version, so a prerelease-suffixed base would predict the wrong tag; - * better to show no preview than a false promise. - */ -function bumpVersion( - base: string | null, - type: ReleaseVersionType -): string | null { - if (!base) return null; - const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(base); - if (!match) return null; - const major = Number(match[1]); - const minor = Number(match[2]); - const patch = Number(match[3]); - if (type === "major") return `v${major + 1}.0.0`; - if (type === "minor") return `v${major}.${minor + 1}.0`; - return `v${major}.${minor}.${patch + 1}`; -} - -function formatAgo(ts: number, now: number): string { - const seconds = Math.max(0, Math.floor((now - ts) / 1000)); - if (seconds < 10) return "just now"; - if (seconds < 60) return `${seconds}s ago`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - return `${hours}h ago`; -} export function ReleasesAdmin(): JSX.Element { const stream = useReleaseStream("create"); @@ -89,77 +23,27 @@ export function ReleasesAdmin(): JSX.Element { streamClientId, } = stream; - const [info, setInfo] = useState(null); - const [infoLoading, setInfoLoading] = useState(false); - const [infoError, setInfoError] = useState(null); - const [lastCheckedAt, setLastCheckedAt] = useState(null); - const [now, setNow] = useState(() => Date.now()); + const { + info, + infoLoading, + infoError, + lastCheckedAt, + now, + releases, + releasesLoading, + promotingTag, + confirmPromoteTag, + promoteError, + setConfirmPromoteTag, + refresh, + promote, + } = useReleaseAdminData(streamClientId); + const [releaseError, setReleaseError] = useState(null); const [confirmType, setConfirmType] = useState( null ); const [showProgress, setShowProgress] = useState(false); - const [releases, setReleases] = useState([]); - const [releasesLoading, setReleasesLoading] = useState(false); - const [promotingTag, setPromotingTag] = useState(null); - const [confirmPromoteTag, setConfirmPromoteTag] = useState( - null - ); - const [promoteError, setPromoteError] = useState(null); - - const fetchInfo = useCallback(async () => { - setInfoLoading(true); - setInfoError(null); - try { - // The client id lets the server stream info-progress events for - // this request over the release SSE stream while git/GitHub run. - const res = await fetch("/api/v1/release/info", { - headers: { "x-dispatch-release-client-id": streamClientId }, - }); - if (!res.ok) { - const err = (await res.json()) as { error?: string }; - setInfoError(cleanError(err.error ?? "Failed to load release info")); - return; - } - setInfo((await res.json()) as ReleaseInfo); - setLastCheckedAt(Date.now()); - setNow(Date.now()); - } catch (err) { - setInfoError( - err instanceof Error - ? cleanError(err.message) - : "Failed to load release info" - ); - } finally { - setInfoLoading(false); - } - }, [streamClientId]); - - const fetchReleases = useCallback(async () => { - setReleasesLoading(true); - try { - const res = await fetch("/api/v1/releases"); - if (res.ok) { - const data = (await res.json()) as { releases: GitHubRelease[] }; - setReleases(data.releases); - } - } catch { - /* ignore */ - } finally { - setReleasesLoading(false); - } - }, []); - - useEffect(() => { - void fetchInfo(); - void fetchReleases(); - }, [fetchInfo, fetchReleases]); - - // Tick so the "checked Xs ago" label stays honest without refetching. - useEffect(() => { - const timer = setInterval(() => setNow(Date.now()), 30_000); - return () => clearInterval(timer); - }, []); const createJob = job?.jobType === "create" ? job : null; const isDone = createJob?.phase === "done"; @@ -187,10 +71,9 @@ export function ReleasesAdmin(): JSX.Element { handledDoneRef.current !== createJob.startedAt ) { handledDoneRef.current = createJob.startedAt; - void fetchInfo(); - void fetchReleases(); + refresh(); } - }, [isDone, watchedRelease, createJob, fetchInfo, fetchReleases]); + }, [isDone, watchedRelease, createJob, refresh]); const handleRelease = async (versionType: ReleaseVersionType) => { setReleaseError(null); @@ -220,35 +103,6 @@ export function ReleasesAdmin(): JSX.Element { setShowProgress(true); }; - const handlePromote = async (tag: string) => { - setPromotingTag(tag); - setConfirmPromoteTag(null); - setPromoteError(null); - try { - const res = await fetch("/api/v1/release/promote", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tag }), - }); - if (res.ok) { - setReleases((prev) => - prev.map((r) => (r.tag === tag ? { ...r, isPrerelease: false } : r)) - ); - } else { - const err = (await res.json()) as { error?: string }; - setPromoteError(cleanError(err.error ?? `Failed to promote ${tag}`)); - } - } catch (err) { - setPromoteError( - err instanceof Error - ? cleanError(err.message) - : `Failed to promote ${tag}` - ); - } finally { - setPromotingTag(null); - } - }; - // The most recent GitHub release (prereleases included) is what the // release workflow bumps from; fall back to the deployed tag. const bumpBase = releases[0]?.tag ?? info?.currentTag ?? null; @@ -295,353 +149,49 @@ export function ReleasesAdmin(): JSX.Element { return (
{/* In-flight / finished release banner — page stays usable */} - {createJob && releaseInFlight && ( -
- - - Releasing{" "} - - {createJob.versionType} - - {createJob.tag && ( - <> - {" "} - {createJob.tag} - - )}{" "} - — {createJob.phase} - - -
- )} - {createJob && isFailed && ( -
- - - Release failed - {createJob.error ? `: ${cleanError(createJob.error)}` : ""} - -
- - -
-
+ {createJob && ( + setShowProgress(true)} + onDismiss={dismissJob} + /> )} - {createJob && isDone && watchedRelease && ( -
- - - Released{" "} - {createJob.tag} - - -
- )} - - {/* Unreleased commits */} -
-
-
- Unreleased changes -
-
- {lastCheckedAt !== null && !infoLoading && ( - - Checked {formatAgo(lastCheckedAt, now)} - - )} - -
-
- - {infoLoading && ( -
- - {infoProgress?.label ?? "Loading..."} - {infoProgress?.detail && ( - - {infoProgress.detail} - - )} -
- )} - {infoError && ( -
- {infoError} -
- )} - - {info && !infoLoading && ( - <> - {info.unreleasedFetchError ? ( -
- Couldn't refresh origin/main{" "} - — unreleased commit info is unavailable, not necessarily zero. -
- {cleanError(info.unreleasedFetchError)} -
-
- ) : info.refMissing ? ( -
- Deployed version{" "} - - {info.currentTag ?? "unknown"} - {" "} - not found in origin — commit count unavailable. -
- ) : info.unreleasedCount === 0 ? ( -
- No unreleased commits on main -
- ) : ( -
-
- {info.unreleasedCount} unreleased{" "} - {info.unreleasedCount === 1 ? "commit" : "commits"} on{" "} - main -
-
- {info.commits.map((c) => ( -
- - {c.sha} - - {c.subject} -
- ))} - {info.unreleasedCount > info.commits.length && ( -
- +{info.unreleasedCount - info.commits.length} more -
- )} -
-
- )} - - )} -
- - {/* Create release */} - {info && - (info.refMissing || - Boolean(info.unreleasedFetchError) || - info.unreleasedCount > 0) && ( -
-
- Create release -
- - {releaseError && ( -
- {releaseError} -
- )} - -
- {(["patch", "minor", "major"] as ReleaseVersionType[]).map( - (type) => { - const { icon: Icon, color } = VERSION_CONFIG[type]; - const nextTag = bumpVersion(bumpBase, type); - return ( - - ); - } - )} -
- - { - if (!open) setConfirmType(null); - }} - > - - - - Create {confirmType ?? ""} release - {confirmType && bumpVersion(bumpBase, confirmType) && ( - <> - {" "} - - {bumpVersion(bumpBase, confirmType)} - - - )} - - - This triggers the release workflow against{" "} - main on GitHub. - - -
- - -
-
-
-
- )} + + + void handleRelease(versionType)} + />
- {/* Recent releases */} -
-
- Recent releases -
- - {promoteError && ( -
- {promoteError} -
- )} - - {releasesLoading && releases.length === 0 && ( -
- - Loading... -
- )} - - {releases.length > 0 && ( -
- {releases.map((r) => ( -
- - {r.tag} - - - {r.isPrerelease ? "pre-release" : "stable"} - - - {formatShortDateTime(r.publishedAt)} - -
- {r.isPrerelease && confirmPromoteTag !== r.tag && ( - - )} - {r.isPrerelease && confirmPromoteTag === r.tag && ( - <> - - Mark stable + latest? - - - - - )} - {!r.isPrerelease && ( - - )} - - - -
-
- ))} -
- )} - - {!releasesLoading && releases.length === 0 && ( -
No releases found
- )} -
+ void promote(tag)} + />
); } diff --git a/apps/web/src/components/app/release-utils.test.ts b/apps/web/src/components/app/release-utils.test.ts index 9a2fe5c1..e5653ec7 100644 --- a/apps/web/src/components/app/release-utils.test.ts +++ b/apps/web/src/components/app/release-utils.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { + bumpVersion, cleanError, describeForceTriggers, + formatAgo, formatBytes, formatInlineProgress, formatProgressLabel, @@ -284,3 +286,40 @@ describe("isAssistedPreferred", () => { ).toBe(false); }); }); + +describe("bumpVersion", () => { + it("bumps patch, minor, and major from a stable tag", () => { + expect(bumpVersion("v1.2.3", "patch")).toBe("v1.2.4"); + expect(bumpVersion("v1.2.3", "minor")).toBe("v1.3.0"); + expect(bumpVersion("v1.2.3", "major")).toBe("v2.0.0"); + }); + + it("accepts a base without the v prefix", () => { + expect(bumpVersion("0.34.3", "patch")).toBe("v0.34.4"); + }); + + it("returns null for a missing or prerelease-suffixed base", () => { + expect(bumpVersion(null, "patch")).toBeNull(); + expect(bumpVersion("v1.2.3-rc.1", "patch")).toBeNull(); + expect(bumpVersion("nightly", "minor")).toBeNull(); + }); +}); + +describe("formatAgo", () => { + const now = 1_000_000_000_000; + + it("collapses the first ten seconds to 'just now'", () => { + expect(formatAgo(now, now)).toBe("just now"); + expect(formatAgo(now - 9_000, now)).toBe("just now"); + }); + + it("steps up through seconds, minutes, and hours", () => { + expect(formatAgo(now - 30_000, now)).toBe("30s ago"); + expect(formatAgo(now - 90_000, now)).toBe("1m ago"); + expect(formatAgo(now - 3 * 3_600_000, now)).toBe("3h ago"); + }); + + it("clamps a future timestamp to 'just now'", () => { + expect(formatAgo(now + 5_000, now)).toBe("just now"); + }); +}); diff --git a/apps/web/src/components/app/release-utils.ts b/apps/web/src/components/app/release-utils.ts index b493f235..bf44544b 100644 --- a/apps/web/src/components/app/release-utils.ts +++ b/apps/web/src/components/app/release-utils.ts @@ -2,6 +2,7 @@ import type { ReleaseInfo, ReleaseJob, ReleaseProgress, + ReleaseVersionType, } from "@/hooks/use-release-stream"; import type { ReleaseInfoSnapshot } from "@/hooks/use-cached-release-info"; import { formatBytes } from "../../../../server/src/shared/lib/format-bytes"; @@ -16,6 +17,13 @@ export type AppVersionInfo = { releaseUrl: string | null; }; +export type GitHubRelease = { + tag: string; + publishedAt: string; + isPrerelease: boolean; + url: string; +}; + export const UPDATE_PHASES = [ "fetching", "deploying", @@ -23,6 +31,44 @@ export const UPDATE_PHASES = [ "done", ] as const; +export const CREATE_PHASES = [ + "preflight", + "triggering", + "watching", + "done", +] as const; + +/** + * Predict the tag the release workflow will cut for a version bump. + * Exact stable tags only — the workflow bumps from main's package + * version, so a prerelease-suffixed base would predict the wrong tag; + * better to show no preview than a false promise. + */ +export function bumpVersion( + base: string | null, + type: ReleaseVersionType +): string | null { + if (!base) return null; + const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(base); + if (!match) return null; + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + if (type === "major") return `v${major + 1}.0.0`; + if (type === "minor") return `v${major}.${minor + 1}.0`; + return `v${major}.${minor}.${patch + 1}`; +} + +export function formatAgo(ts: number, now: number): string { + const seconds = Math.max(0, Math.floor((now - ts) / 1000)); + if (seconds < 10) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} + export function cleanError(raw: string): string { const stderrMatch = raw.match(/stderr=(.+)$/s); if (stderrMatch) { diff --git a/apps/web/src/components/app/use-release-admin-data.ts b/apps/web/src/components/app/use-release-admin-data.ts new file mode 100644 index 00000000..9edb832a --- /dev/null +++ b/apps/web/src/components/app/use-release-admin-data.ts @@ -0,0 +1,150 @@ +import { useCallback, useEffect, useState } from "react"; +import type { GitHubRelease } from "@/components/app/release-utils"; +import { cleanError } from "@/components/app/release-utils"; +import type { ReleaseInfo } from "@/hooks/use-release-stream"; + +export type UseReleaseAdminDataResult = { + info: ReleaseInfo | null; + infoLoading: boolean; + infoError: string | null; + lastCheckedAt: number | null; + now: number; + releases: GitHubRelease[]; + releasesLoading: boolean; + promotingTag: string | null; + confirmPromoteTag: string | null; + promoteError: string | null; + setConfirmPromoteTag: (tag: string | null) => void; + refresh: () => void; + promote: (tag: string) => Promise; +}; + +/** + * Data layer for the admin Releases page: the unreleased-commit info, the + * recent GitHub releases list, and the promote-to-stable mutation that + * writes back into that list. Kept out of the render shell so the page + * components stay presentational. + * + * `streamClientId` comes from the release SSE stream so the server can push + * info-progress events for the in-flight /release/info request. + */ +export function useReleaseAdminData( + streamClientId: string +): UseReleaseAdminDataResult { + const [info, setInfo] = useState(null); + const [infoLoading, setInfoLoading] = useState(false); + const [infoError, setInfoError] = useState(null); + const [lastCheckedAt, setLastCheckedAt] = useState(null); + const [now, setNow] = useState(() => Date.now()); + const [releases, setReleases] = useState([]); + const [releasesLoading, setReleasesLoading] = useState(false); + const [promotingTag, setPromotingTag] = useState(null); + const [confirmPromoteTag, setConfirmPromoteTag] = useState( + null + ); + const [promoteError, setPromoteError] = useState(null); + + const fetchInfo = useCallback(async () => { + setInfoLoading(true); + setInfoError(null); + try { + // The client id lets the server stream info-progress events for + // this request over the release SSE stream while git/GitHub run. + const res = await fetch("/api/v1/release/info", { + headers: { "x-dispatch-release-client-id": streamClientId }, + }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + setInfoError(cleanError(err.error ?? "Failed to load release info")); + return; + } + setInfo((await res.json()) as ReleaseInfo); + setLastCheckedAt(Date.now()); + setNow(Date.now()); + } catch (err) { + setInfoError( + err instanceof Error + ? cleanError(err.message) + : "Failed to load release info" + ); + } finally { + setInfoLoading(false); + } + }, [streamClientId]); + + const fetchReleases = useCallback(async () => { + setReleasesLoading(true); + try { + const res = await fetch("/api/v1/releases"); + if (res.ok) { + const data = (await res.json()) as { releases: GitHubRelease[] }; + setReleases(data.releases); + } + } catch { + /* ignore */ + } finally { + setReleasesLoading(false); + } + }, []); + + const refresh = useCallback(() => { + void fetchInfo(); + void fetchReleases(); + }, [fetchInfo, fetchReleases]); + + useEffect(() => { + void fetchInfo(); + void fetchReleases(); + }, [fetchInfo, fetchReleases]); + + // Tick so the "checked Xs ago" label stays honest without refetching. + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, []); + + const promote = useCallback(async (tag: string) => { + setPromotingTag(tag); + setConfirmPromoteTag(null); + setPromoteError(null); + try { + const res = await fetch("/api/v1/release/promote", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tag }), + }); + if (res.ok) { + setReleases((prev) => + prev.map((r) => (r.tag === tag ? { ...r, isPrerelease: false } : r)) + ); + } else { + const err = (await res.json()) as { error?: string }; + setPromoteError(cleanError(err.error ?? `Failed to promote ${tag}`)); + } + } catch (err) { + setPromoteError( + err instanceof Error + ? cleanError(err.message) + : `Failed to promote ${tag}` + ); + } finally { + setPromotingTag(null); + } + }, []); + + return { + info, + infoLoading, + infoError, + lastCheckedAt, + now, + releases, + releasesLoading, + promotingTag, + confirmPromoteTag, + promoteError, + setConfirmPromoteTag, + refresh, + promote, + }; +}