import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { repoDb } from "~/server/scoped-db"; import { requireRepoAccess } from "~/server/repos"; import { accountOrGhost, accountsById } from "~/server/people"; import { reviewDeployment as decideDeployment } from "~/server/ci/deployments"; import type { DeploymentRow } from "~/server/ci/deployments"; import { cacheQuotaBytes, deleteCacheEntry } from "~/server/ci/cache-api"; import { forbidden } from "~/server/errors"; import { limitBrowse, limitWrite } from "~/server/limits"; import { repoRef } from "~/lib/validation"; const DEPLOYMENT_PAGE = 50; const CACHE_PAGE = 100; export const listDeployments = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ environment: z.string().optional() })) .handler(async ({ data, context }) => { await limitBrowse(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const environments = await repoDb(repo.id).all<{ id: string; name: string }>( "SELECT id, name FROM environments WHERE repo_id = ? ORDER BY name", repo.id, ); const selected = environments.find((environment) => environment.name === data.environment) ?? null; const rows = await repoDb(repo.id).all< DeploymentRow & { environment_name: string; run_number: number; workflow_name: string } >( `SELECT d.*, e.name AS environment_name, w.run_number, w.workflow_name FROM deployments d JOIN environments e ON e.id = d.environment_id JOIN workflow_runs w ON w.id = d.run_id WHERE d.repo_id = ? ${selected ? "AND d.environment_id = ?" : ""} ORDER BY d.created_at DESC LIMIT ?`, ...(selected ? [repo.id, selected.id, DEPLOYMENT_PAGE] : [repo.id, DEPLOYMENT_PAGE]), ); const people = await accountsById(rows.flatMap((row) => [row.creator_id, row.reviewed_by])); const latest = new Map(); for (const row of rows) if (!latest.has(row.environment_id) && row.status === "success") latest.set(row.environment_id, row); return { environments: environments.map((environment) => ({ name: environment.name, activeUrl: latest.get(environment.id)?.url ?? null, lastDeployedAt: latest.get(environment.id)?.updated_at ?? null, })), deployments: rows.map((row) => ({ id: row.id, environment: row.environment_name, status: row.status, ref: row.ref.replace(/^refs\/(heads|tags)\//, ""), sha: row.sha, url: row.url, runNumber: row.run_number, workflowName: row.workflow_name, creator: row.creator_id ? accountOrGhost(people, row.creator_id) : null, reviewer: row.reviewed_by ? accountOrGhost(people, row.reviewed_by) : null, createdAt: row.created_at, updatedAt: row.updated_at, })), }; }); export const reviewDeployment = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string().min(1), approve: z.boolean() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "read"); if (!(await decideDeployment(repo.id, data.id, viewer.id, data.approve))) { throw forbidden("You are not a required reviewer for this deployment"); } return { ok: true }; }); export const listCaches = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { await limitBrowse(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "write"); const rows = await repoDb(repo.id).all<{ id: string; ref: string; key: string; size: number; created_at: number; last_used_at: number; }>( `SELECT id, ref, key, size, created_at, last_used_at FROM action_caches WHERE repo_id = ? AND size > 0 ORDER BY last_used_at DESC LIMIT ?`, repo.id, CACHE_PAGE, ); const usage = await repoDb(repo.id).first<{ bytes: number | null; count: number }>( "SELECT SUM(ABS(size)) AS bytes, COUNT(*) AS count FROM action_caches WHERE repo_id = ?", repo.id, ); return { usedBytes: usage?.bytes ?? 0, count: usage?.count ?? 0, quotaBytes: cacheQuotaBytes(), caches: rows.map((row) => ({ id: row.id, ref: row.ref.replace(/^refs\/heads\//, ""), key: row.key, size: row.size, createdAt: row.created_at, lastUsedAt: row.last_used_at, })), }; }); export const deleteCache = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string().min(1) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "write"); await deleteCacheEntry(repo.id, data.id); return { ok: true }; });