diff --git a/soundcheck/src/app/page.tsx b/soundcheck/src/app/page.tsx index d2299aec88..880ac61d80 100644 --- a/soundcheck/src/app/page.tsx +++ b/soundcheck/src/app/page.tsx @@ -1,5 +1,10 @@ +import { Suspense } from "react"; import { withAuth } from "@workos-inc/authkit-nextjs"; import { isAllowedEmployeeEmail, isLockdownEnabled } from "@/lib/lockdown"; +import { + DeployDiff, + DeployDiffSkeleton +} from "@/components/deploy-diff"; export const dynamic = "force-dynamic"; @@ -18,7 +23,7 @@ export default async function Home() { } return ( -
+

Soundcheck

@@ -26,16 +31,38 @@ export default async function Home() {

-
-

- Scaffold only +
+

+ Deploy diff

-

- Deploy-diff, release readiness, release progress stepper, and drift - alerts land in follow-up commits. See{" "} - soundcheck/README.md for the feature list. +

+ What production is missing vs. staging. Use this to decide whether + to cut a release.

+ +
+ }> + + + }> + + +

); } diff --git a/soundcheck/src/components/deploy-diff.tsx b/soundcheck/src/components/deploy-diff.tsx new file mode 100644 index 0000000000..1a64b38842 --- /dev/null +++ b/soundcheck/src/components/deploy-diff.tsx @@ -0,0 +1,218 @@ +import { + compareCommits, + getLatestEnvironmentDeployment +} from "@/lib/github"; + +interface Props { + title: string; + owner: string; + repo: string; + stagingEnvironment: string; + productionEnvironment: string; + /** Public repo URL for linking SHAs in the staging+production sync case. */ + repoUrl: string; +} + +function formatRelativeTime(iso: string): string { + const diffMs = Date.now() - new Date(iso).getTime(); + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + if (diffMs >= day) return `${Math.floor(diffMs / day)}d ago`; + if (diffMs >= hour) return `${Math.floor(diffMs / hour)}h ago`; + if (diffMs >= minute) return `${Math.floor(diffMs / minute)}m ago`; + return "just now"; +} + +type Category = "feat" | "fix" | "chore" | "other"; + +function categorize(message: string): Category { + const m = message.toLowerCase(); + if (/^feat(\(|:|!)/.test(m)) return "feat"; + if (/^fix(\(|:|!)/.test(m)) return "fix"; + if (/^(chore|docs|refactor|test|build|ci|style|perf)(\(|:|!)/.test(m)) { + return "chore"; + } + return "other"; +} + +function describeCategories( + counts: Record, + total: number +): string { + const parts: string[] = []; + if (counts.feat > 0) parts.push(`${counts.feat} feature${counts.feat === 1 ? "" : "s"}`); + if (counts.fix > 0) parts.push(`${counts.fix} fix${counts.fix === 1 ? "" : "es"}`); + if (counts.chore > 0) parts.push(`${counts.chore} chore${counts.chore === 1 ? "" : "s"}`); + if (counts.other > 0) { + parts.push(`${counts.other} other commit${counts.other === 1 ? "" : "s"}`); + } + return parts.length > 0 ? parts.join(", ") : `${total} commit${total === 1 ? "" : "s"}`; +} + +function Tile({ + title, + children +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +export async function DeployDiff({ + title, + owner, + repo, + stagingEnvironment, + productionEnvironment, + repoUrl +}: Props) { + let staging, production; + try { + [staging, production] = await Promise.all([ + getLatestEnvironmentDeployment(owner, repo, stagingEnvironment), + getLatestEnvironmentDeployment(owner, repo, productionEnvironment) + ]); + } catch (err) { + return ( + +

+ Failed to read deployments: {(err as Error).message} +

+
+ ); + } + + if (!production) { + return ( + +

+ No successful production deployment recorded for{" "} + {productionEnvironment}. +

+
+ ); + } + if (!staging) { + return ( + +

+ No successful staging deployment recorded for{" "} + {stagingEnvironment}. +

+
+ ); + } + + if (staging.sha === production.sha) { + return ( + +

+ In sync on{" "} + + {production.sha.slice(0, 7)} + + . Last promoted {formatRelativeTime(production.createdAt)}. +

+
+ ); + } + + let diff; + try { + diff = await compareCommits(owner, repo, production.sha, staging.sha); + } catch (err) { + return ( + +

+ Failed to compare commits: {(err as Error).message} +

+
+ ); + } + + const counts: Record = { + feat: 0, + fix: 0, + chore: 0, + other: 0 + }; + for (const c of diff.commits) { + counts[categorize(c.message)]++; + } + + const commitWord = diff.aheadBy === 1 ? "commit" : "commits"; + const breakdown = describeCategories(counts, diff.aheadBy); + const compareUrl = `${repoUrl}/compare/${production.sha}...${staging.sha}`; + + // GitHub Compare returns commits in chronological order (oldest first). + // Flip so the newest work shows up on top — that's what matters when + // deciding "should we cut a release?". Overflow count uses `aheadBy` + // rather than `commits.length` because the compare endpoint caps commits + // at 250 for wide diffs. + const preview = diff.commits.slice(-8).reverse(); + const hidden = diff.aheadBy - preview.length; + + return ( + +

+ Production is{" "} + + {diff.aheadBy} {commitWord} behind staging + {" "} + ({breakdown}). Last promoted{" "} + {formatRelativeTime(production.createdAt)}. +

+ +
    + {preview.map((c) => ( +
  • + + {c.sha.slice(0, 7)} + {" "} + + {c.message} + {" "} + — {c.author} +
  • + ))} + {hidden > 0 && ( +
  • + + {hidden} earlier commit{hidden === 1 ? "" : "s"} +
  • + )} +
+
+ ); +} + +export function DeployDiffSkeleton({ title }: { title: string }) { + return ( + +

Loading…

+
+ ); +} diff --git a/soundcheck/src/lib/github.ts b/soundcheck/src/lib/github.ts new file mode 100644 index 0000000000..c8e95c41ec --- /dev/null +++ b/soundcheck/src/lib/github.ts @@ -0,0 +1,165 @@ +/** + * Thin wrapper around the GitHub REST API for the reads Soundcheck + * needs: latest environment deployments and commit comparisons. + * + * Auth: `GITHUB_PAT` must be a fine-grained token with read-only access to + * `contents`, `deployments`, `metadata`, and `actions` on the inspector and + * backend repos. + */ + +const GITHUB_API = "https://api.github.com"; + +function authHeaders(): Record { + const token = process.env.GITHUB_PAT; + if (!token) { + throw new Error("GITHUB_PAT is not set"); + } + return { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" + }; +} + +async function githubFetch(path: string): Promise { + const res = await fetch(`${GITHUB_API}${path}`, { + headers: authHeaders(), + // Short revalidation: dashboard data is live-ish, GitHub rate limits are + // generous enough that this is fine. + next: { revalidate: 30 } + }); + if (!res.ok) { + // Log the full body server-side for debugging, but surface only a + // parsed `message` field (if present) in the user-facing error — avoids + // dumping unfiltered upstream JSON into the rendered DOM. + const rawBody = await res.text(); + console.error(`GitHub API ${res.status} for ${path}:`, rawBody); + let upstreamMessage: string | null = null; + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + try { + const parsed = JSON.parse(rawBody) as { message?: unknown }; + if (typeof parsed.message === "string") { + upstreamMessage = parsed.message; + } + } catch { + // malformed JSON; fall through to generic message + } + } + throw new Error( + upstreamMessage + ? `GitHub API ${res.status}: ${upstreamMessage}` + : `GitHub API ${res.status} for ${path}` + ); + } + return res.json(); +} + +export interface DeploymentInfo { + sha: string; + createdAt: string; + creator: string | null; + environment: string; + url: string; +} + +async function getLatestDeploymentStatus( + owner: string, + repo: string, + deploymentId: number +): Promise { + const path = `/repos/${owner}/${repo}/deployments/${deploymentId}/statuses?per_page=1`; + const data = (await githubFetch(path)) as Array<{ state: string }>; + return data[0]?.state ?? null; +} + +/** + * Latest successful deployment for a GitHub Environment. + * + * The list-deployments endpoint returns every deployment regardless of + * status — including `pending`, `in_progress`, and `failure`. For the + * dashboard we want the SHA that is actually running, so we walk back + * through the most recent deployments and return the first whose latest + * status is `success` (deployed cleanly) or `inactive` (deployed cleanly + * but has since been superseded — still what was last live if no newer + * deployment has reached `success` yet). + */ +export async function getLatestEnvironmentDeployment( + owner: string, + repo: string, + environment: string +): Promise { + const path = `/repos/${owner}/${repo}/deployments?environment=${encodeURIComponent(environment)}&per_page=10`; + const data = (await githubFetch(path)) as Array<{ + id: number; + sha: string; + created_at: string; + creator: { login: string } | null; + environment: string; + url: string; + }>; + + for (const d of data) { + const state = await getLatestDeploymentStatus(owner, repo, d.id); + if (state === "success" || state === "inactive") { + return { + sha: d.sha, + createdAt: d.created_at, + creator: d.creator?.login ?? null, + environment: d.environment, + url: d.url + }; + } + } + return null; +} + +export interface CompareCommit { + sha: string; + message: string; + author: string; + date: string; + url: string; +} + +export interface CompareResult { + aheadBy: number; + behindBy: number; + commits: CompareCommit[]; +} + +/** + * Commits reachable from `head` but not `base`. `aheadBy` is the true count. + * + * The compare endpoint caps `commits` at 250 items, so for very wide diffs + * `commits.length` can be less than `aheadBy`. Always use `aheadBy` when + * reporting counts to the user; the `commits` array is only for preview. + */ +export async function compareCommits( + owner: string, + repo: string, + base: string, + head: string +): Promise { + const path = `/repos/${owner}/${repo}/compare/${base}...${head}`; + const data = (await githubFetch(path)) as { + ahead_by: number; + behind_by: number; + commits: Array<{ + sha: string; + commit: { message: string; author: { name: string; date: string } }; + html_url: string; + }>; + }; + return { + aheadBy: data.ahead_by, + behindBy: data.behind_by, + commits: data.commits.map((c) => ({ + sha: c.sha, + message: c.commit.message.split("\n")[0], + author: c.commit.author.name, + date: c.commit.author.date, + url: c.html_url + })) + }; +}