diff --git a/package-lock.json b/package-lock.json index 5f93b8a..20bb9a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2426,6 +2426,18 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/drizzle-kit": { "version": "0.31.10", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", @@ -4809,6 +4821,7 @@ "version": "1.0.0", "dependencies": { "@libsql/client": "^0.14.0", + "dotenv": "^16.4.5", "drizzle-orm": "^0.45.2", "express": "^4.21.2", "express-session": "^1.18.1", diff --git a/server/package.json b/server/package.json index 74d59ae..c4cd1f2 100644 --- a/server/package.json +++ b/server/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@libsql/client": "^0.14.0", + "dotenv": "^16.4.5", "drizzle-orm": "^0.45.2", "express": "^4.21.2", "express-session": "^1.18.1", diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index 1727053..adf79f8 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -1,3 +1,4 @@ +import "../loadEnv.js"; import { migrate } from "drizzle-orm/libsql/migrator"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; diff --git a/server/src/index.ts b/server/src/index.ts index ecf3b30..6bddfa8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,3 +1,4 @@ +import "./loadEnv.js"; import express from "express"; import session from "express-session"; import FileStoreFactory from "session-file-store"; diff --git a/server/src/integrations/fieldSchemas.ts b/server/src/integrations/fieldSchemas.ts index a2e07a3..d7754b0 100644 --- a/server/src/integrations/fieldSchemas.ts +++ b/server/src/integrations/fieldSchemas.ts @@ -15,6 +15,10 @@ export const INTEGRATION_FIELDS: Partial> = { tailscale: "https://api.tailscale.com", }; +/** + * Resolves the value to store in the integrations.baseUrl column: types that + * collect their own URL as a config field (e.g. Gitea) use that; others fall + * back to a fixed constant (e.g. Tailscale's API is always api.tailscale.com). + */ +export function resolveBaseUrl( + type: IntegrationType, + nonSecretFields: Record, +): string { + const url = nonSecretFields.url; + if (typeof url === "string" && url) return url; + return INTEGRATION_BASE_URLS[type] ?? ""; +} + export function splitIntegrationConfig( type: IntegrationType, input: Record, diff --git a/server/src/integrations/gitea/adapter.ts b/server/src/integrations/gitea/adapter.ts new file mode 100644 index 0000000..4a0d3cd --- /dev/null +++ b/server/src/integrations/gitea/adapter.ts @@ -0,0 +1,154 @@ +/** + * Gitea adapter — uses the Gitea v1 REST API. + * Requires config: url, token + * + * API docs: https://gitea.labsconnect.se/api/swagger (or any instance's /api/swagger) + * Verified against Gitea 1.27. + */ + +export interface GiteaConfig { + url: string; + token: string; +} + +export interface GiteaRepo { + owner: string; + name: string; + fullName: string; + htmlUrl: string; + private: boolean; + hasActions: boolean; + updatedAt: string; +} + +export interface GiteaWorkflowRun { + id: number; + displayTitle: string; + status: string; // e.g. "success" | "failure" | "waiting" | "running" | "cancelled" | "skipped" + conclusion: string | null; + headBranch: string; + event: string; + runNumber: number; + htmlUrl: string; + startedAt: string | null; + completedAt: string | null; +} + +export interface GiteaRepoWithStatus extends GiteaRepo { + latestRun: GiteaWorkflowRun | null; +} + +export interface GiteaAdapter { + ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; + listReposWithStatus(): Promise; + rerunFailedJobs(owner: string, repo: string, runId: number): Promise; +} + +export function createGiteaAdapter(config: GiteaConfig): GiteaAdapter { + function base() { + return config.url.replace(/\/$/, ""); + } + + function headers() { + return { + Authorization: `token ${config.token}`, + Accept: "application/json", + "Content-Type": "application/json", + }; + } + + async function api(method: string, path: string): Promise { + const res = await fetch(`${base()}/api/v1${path}`, { method, headers: headers() }); + if (res.status === 204 || res.status === 201) { + return res.status === 201 ? res.json().catch(() => null) : null; + } + const text = await res.text(); + let data: any = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + // non-JSON error page + } + if (!res.ok) { + throw new Error(data?.message || `Gitea API error: HTTP ${res.status}`); + } + return data; + } + + function mapRepo(r: any): GiteaRepo { + return { + owner: r.owner?.login ?? "", + name: r.name, + fullName: r.full_name, + htmlUrl: r.html_url, + private: !!r.private, + hasActions: !!r.has_actions, + updatedAt: r.updated_at, + }; + } + + function mapRun(r: any): GiteaWorkflowRun { + return { + id: r.id, + displayTitle: r.display_title ?? "", + status: r.status ?? "unknown", + conclusion: r.conclusion ?? null, + headBranch: r.head_branch ?? "", + event: r.event ?? "", + runNumber: r.run_number ?? 0, + htmlUrl: r.html_url ?? "", + startedAt: r.started_at ?? null, + completedAt: r.completed_at ?? null, + }; + } + + async function listRepos(): Promise { + const data = await api("GET", "/user/repos?limit=50&page=1"); + return (Array.isArray(data) ? data : []).map(mapRepo); + } + + async function getLatestRun(owner: string, repo: string): Promise { + try { + const data = await api( + "GET", + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs?limit=1&page=1`, + ); + const run = data?.workflow_runs?.[0]; + return run ? mapRun(run) : null; + } catch { + // Actions may be disabled repo-wide even if has_actions looked true, or the + // token may lack access — treat as "no run info" rather than failing the whole list. + return null; + } + } + + async function listReposWithStatus(): Promise { + const repos = await listRepos(); + const withStatus = await Promise.all( + repos.map(async (r) => ({ + ...r, + latestRun: r.hasActions ? await getLatestRun(r.owner, r.name) : null, + })), + ); + return withStatus; + } + + async function rerunFailedJobs(owner: string, repo: string, runId: number): Promise { + await api( + "POST", + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs/${runId}/rerun-failed-jobs`, + ); + } + + async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + const start = Date.now(); + try { + await api("GET", "/user/repos?limit=1&page=1"); + return { ok: true, latencyMs: Date.now() - start }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + return { ping, listReposWithStatus, rerunFailedJobs }; +} diff --git a/server/src/integrations/registry.ts b/server/src/integrations/registry.ts index 861ecd9..f910b80 100644 --- a/server/src/integrations/registry.ts +++ b/server/src/integrations/registry.ts @@ -1,11 +1,25 @@ import type { IntegrationType } from "../db/schema.js"; import type { IntegrationConfig } from "./types.js"; -import { createTailscaleAdapter, type TailscaleAdapter } from "./tailscale/adapter.js"; +import { createTailscaleAdapter } from "./tailscale/adapter.js"; +import { createGiteaAdapter } from "./gitea/adapter.js"; -export function createIntegrationAdapter(type: IntegrationType, config: IntegrationConfig): TailscaleAdapter { +export interface PingableAdapter { + ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; +} + +/** + * Generic dispatch used only where a ping() is all that's needed (the "test + * connection" endpoint). Type-specific routes construct their own concrete + * adapter directly (see requireTailscaleAdapter / requireGiteaAdapter in + * routes/integrations.ts) so a not-yet-implemented type can never throw here + * for an existing row of some OTHER type. + */ +export function createIntegrationAdapter(type: IntegrationType, config: IntegrationConfig): PingableAdapter { switch (type) { case "tailscale": return createTailscaleAdapter(config as any); + case "gitea": + return createGiteaAdapter(config as any); default: throw new Error(`Integration type "${type}" is not implemented yet`); } diff --git a/server/src/loadEnv.ts b/server/src/loadEnv.ts new file mode 100644 index 0000000..aebd1b2 --- /dev/null +++ b/server/src/loadEnv.ts @@ -0,0 +1,11 @@ +import { config } from "dotenv"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// Side-effect-only module: populates process.env from the repo-root .env +// file. Must be imported before ./env.js anywhere in the import graph — in +// Docker this is a no-op since docker-compose's env_file already injects the +// variables directly, but plain `node dist/index.js` / `tsx src/index.ts` +// otherwise never reads .env at all. +const __dirname = dirname(fileURLToPath(import.meta.url)); +config({ path: join(__dirname, "..", "..", ".env") }); diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index 40d6b90..f11e1db 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -8,13 +8,14 @@ import { recordAudit } from "../services/audit.js"; import { encryptSecret } from "../crypto.js"; import { INTEGRATION_FIELDS, - INTEGRATION_BASE_URLS, + resolveBaseUrl, splitIntegrationConfig, validateIntegrationConfig, } from "../integrations/fieldSchemas.js"; import { createIntegrationAdapter } from "../integrations/registry.js"; import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js"; +import { createGiteaAdapter } from "../integrations/gitea/adapter.js"; import { asyncHandler } from "../utils/asyncHandler.js"; export const integrationsRouter = Router(); @@ -81,7 +82,7 @@ integrationsRouter.post("/", requireRole("admin"), asyncHandler(async (req, res) .values({ type, name, - baseUrl: INTEGRATION_BASE_URLS[type] ?? "", + baseUrl: resolveBaseUrl(type, nonSecretFields), credentialId, config: JSON.stringify(nonSecretFields), enabled: true, @@ -129,6 +130,7 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, let credentialId = existing.credentialId; let configJson = existing.config; + let baseUrl = existing.baseUrl; if (parsed.data.config) { const loaded = await loadIntegrationConfig(id); @@ -138,6 +140,7 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, if (field.secret) delete (mergedNonSecret as Record)[field.key]; } configJson = JSON.stringify(mergedNonSecret); + baseUrl = resolveBaseUrl(existing.type, mergedNonSecret); if (Object.keys(secretFields).length > 0) { const existingSecrets: Record = {}; @@ -166,6 +169,7 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, enabled: parsed.data.enabled ?? existing.enabled, credentialId, config: configJson, + baseUrl, }) .where(eq(integrations.id, id)) .returning(); @@ -332,3 +336,64 @@ integrationsRouter.delete( } }), ); + +// ─── Gitea ─────────────────────────────────────────────────────────────────── + +async function requireGiteaAdapter(req: Request, res: Response) { + const id = Number(req.params.id); + const loaded = await loadIntegrationConfig(id); + if (!loaded) { + res.status(404).json({ error: "not_found" }); + return null; + } + if (loaded.integration.type !== "gitea") { + res.status(400).json({ error: "wrong_type" }); + return null; + } + if (!loaded.integration.enabled) { + res.status(400).json({ error: "integration_disabled" }); + return null; + } + return { integration: loaded.integration, adapter: createGiteaAdapter(loaded.config as any) }; +} + +integrationsRouter.get("/:id/gitea/repos", asyncHandler(async (req, res) => { + const found = await requireGiteaAdapter(req, res); + if (!found) return; + + try { + const repos = await found.adapter.listReposWithStatus(); + res.json({ repos }); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } +})); + +integrationsRouter.post( + "/:id/gitea/repos/:owner/:repo/runs/:runId/rerun-failed", + requireRole("operator"), + asyncHandler(async (req, res) => { + const found = await requireGiteaAdapter(req, res); + if (!found) return; + + const runId = Number(req.params.runId); + if (!Number.isInteger(runId)) { + return res.status(400).json({ error: "invalid_run_id" }); + } + + try { + await found.adapter.rerunFailedJobs(req.params.owner, req.params.repo, runId); + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: "rerun_failed_jobs", + targetType: "gitea_workflow_run", + targetId: `${req.params.owner}/${req.params.repo}#${runId}`, + detail: { integrationId: found.integration.id }, + }); + res.status(204).end(); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } + }), +); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e7ef182..92f2553 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -198,6 +198,30 @@ export interface TailscaleDevicesResponse { summary: { total: number; online: number; unauthorized: number }; } +export interface GiteaWorkflowRun { + id: number; + displayTitle: string; + status: string; + conclusion: string | null; + headBranch: string; + event: string; + runNumber: number; + htmlUrl: string; + startedAt: string | null; + completedAt: string | null; +} + +export interface GiteaRepo { + owner: string; + name: string; + fullName: string; + htmlUrl: string; + private: boolean; + hasActions: boolean; + updatedAt: string; + latestRun: GiteaWorkflowRun | null; +} + export class UnauthorizedError extends Error {} export class ForbiddenError extends Error {} @@ -368,5 +392,13 @@ export const api = { method: "DELETE", }), }, + gitea: { + repos: (integrationId: number) => request<{ repos: GiteaRepo[] }>(`/api/integrations/${integrationId}/gitea/repos`), + rerunFailed: (integrationId: number, owner: string, repo: string, runId: number) => + request( + `/api/integrations/${integrationId}/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/runs/${runId}/rerun-failed`, + { method: "POST" }, + ), + }, }, }; diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 8ad39a3..7bf4166 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -11,9 +11,15 @@ const WIDGETS: { type: IntegrationType; label: string }[] = [ { type: "dockhand", label: "Dockhand / Docker" }, ]; +interface GiteaSummary { + repoCount: number; + failing: number; +} + export default function Dashboard({ user }: { user: CurrentUser }) { const [integrations, setIntegrations] = useState(null); const [tailscaleSummary, setTailscaleSummary] = useState(null); + const [giteaSummary, setGiteaSummary] = useState(null); useEffect(() => { api.integrations.list().then((res) => setIntegrations(res.integrations)); @@ -31,6 +37,23 @@ export default function Dashboard({ user }: { user: CurrentUser }) { .catch(() => setTailscaleSummary(null)); }, [integrations]); + useEffect(() => { + const gitea = integrations?.find((i) => i.type === "gitea" && i.enabled); + if (!gitea) { + setGiteaSummary(null); + return; + } + api.integrations.gitea + .repos(gitea.id) + .then((res) => + setGiteaSummary({ + repoCount: res.repos.length, + failing: res.repos.filter((r) => r.latestRun?.conclusion === "failure" || r.latestRun?.status === "failure").length, + }), + ) + .catch(() => setGiteaSummary(null)); + }, [integrations]); + return ( <>
@@ -46,7 +69,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
{WIDGETS.map(({ type, label }) => { const integration = integrations?.find((i) => i.type === type && i.enabled); - const isLive = type === "tailscale" && integration && tailscaleSummary; + const isLiveTailscale = type === "tailscale" && integration && tailscaleSummary; + const isLiveGitea = type === "gitea" && integration && giteaSummary; + const isLive = isLiveTailscale || isLiveGitea; return (
@@ -64,7 +89,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) { )}
- {isLive ? ( + {isLiveTailscale ? (
{tailscaleSummary!.online}/{tailscaleSummary!.total} @@ -74,6 +99,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
{tailscaleSummary!.unauthorized} awaiting authorization
)}
+ ) : isLiveGitea ? ( +
+
{giteaSummary!.repoCount}
+
repositories
+ {giteaSummary!.failing > 0 && ( +
{giteaSummary!.failing} with a failing build
+ )} +
) : (
{integration ? ( diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 97b5070..1f203d3 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { api, type CurrentUser, + type GiteaRepo, type IntegrationSummary, type IntegrationType, type TailscaleDevice, @@ -9,6 +10,31 @@ import { } from "../api/client"; import IntegrationForm from "../components/IntegrationForm"; +function runStatusBadge(repo: GiteaRepo) { + const run = repo.latestRun; + if (!run) return No runs; + const state = run.conclusion || run.status; + switch (state) { + case "success": + return Success; + case "failure": + return Failed; + case "cancelled": + return Cancelled; + case "skipped": + return Skipped; + case "waiting": + case "queued": + case "pending": + return Queued; + case "running": + case "in_progress": + return Running; + default: + return {state}; + } +} + const TYPE_LABELS: Record = { tailscale: "Tailscale", proxmox: "Proxmox", @@ -31,6 +57,11 @@ export default function Integrations({ user }: { user: CurrentUser }) { const [tailscaleError, setTailscaleError] = useState(null); const [loadingDevices, setLoadingDevices] = useState(false); + const [giteaRepos, setGiteaRepos] = useState(null); + const [giteaError, setGiteaError] = useState(null); + const [loadingRepos, setLoadingRepos] = useState(false); + const [rerunningRun, setRerunningRun] = useState(null); + function loadIntegrations() { api.integrations .list() @@ -55,12 +86,27 @@ export default function Integrations({ user }: { user: CurrentUser }) { .finally(() => setLoadingDevices(false)); } + function loadGiteaRepos(id: number) { + setLoadingRepos(true); + setGiteaError(null); + api.integrations.gitea + .repos(id) + .then((res) => setGiteaRepos(res.repos)) + .catch((err) => setGiteaError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoadingRepos(false)); + } + useEffect(() => { if (selected?.type === "tailscale" && selected.enabled && !managing) { loadTailscaleDevices(selected.id); } else { setTailscaleData(null); } + if (selected?.type === "gitea" && selected.enabled && !managing) { + loadGiteaRepos(selected.id); + } else { + setGiteaRepos(null); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId, managing, integrations]); @@ -105,6 +151,20 @@ export default function Integrations({ user }: { user: CurrentUser }) { } } + async function rerunFailed(repo: GiteaRepo) { + if (!selectedId || !repo.latestRun) return; + setRerunningRun(repo.latestRun.id); + setGiteaError(null); + try { + await api.integrations.gitea.rerunFailed(selectedId, repo.owner, repo.name, repo.latestRun.id); + loadGiteaRepos(selectedId); + } catch (err) { + setGiteaError(err instanceof Error ? err.message : String(err)); + } finally { + setRerunningRun(null); + } + } + return ( <>
@@ -276,6 +336,76 @@ export default function Integrations({ user }: { user: CurrentUser }) {
+ ) : selected?.type === "gitea" ? ( +
+
+

Repositories

+
+ +
+
+ {giteaError &&
{giteaError}
} +
+ + + + + + + + {canEdit && } + + + + {giteaRepos?.map((r) => ( + + + + + + {canEdit && ( + + )} + + ))} + {giteaRepos?.length === 0 && ( + + + + )} + +
RepositoryLast runBranchStatusActions
+ + {r.fullName} + + {r.private && Private} + + {r.latestRun ? ( + + #{r.latestRun.runNumber} {r.latestRun.displayTitle} + + ) : r.hasActions ? ( + "—" + ) : ( + "Actions disabled" + )} + {r.latestRun?.headBranch ?? "—"}{runStatusBadge(r)} + {r.latestRun && (r.latestRun.conclusion === "failure" || r.latestRun.status === "failure") && ( + + )} +
+ No repositories visible to this token. +
+
+
) : (