Add Gitea integration; fix .env never being loaded outside Docker

Second live integration: repo list with last CI run status, and re-running
failed jobs on a workflow run — matching the "dashboard + basic actions"
depth from the plan. Adapter built directly against the real Gitea 1.27
swagger spec (fetched from the user's own instance) rather than guessing at
the API shape: GET /user/repos for the repo list, GET
/repos/{owner}/{repo}/actions/runs?limit=1 for the latest run per repo (only
for repos with Actions enabled), and POST .../rerun-failed-jobs for retrying
just the failed jobs in a run. Follows the same config-in-UI +
encrypted-credential pattern as Tailscale and DNS providers.

Since Gitea collects its own base URL as a config field (unlike Tailscale,
which always talks to a fixed api.tailscale.com), generalized the
"integrations.baseUrl" bookkeeping into resolveBaseUrl() instead of the
one-fixed-URL-per-type map used previously.

Also fixed a real gap found while setting this up: server/src/env.ts reads
process.env directly, but nothing in the app ever loaded .env into
process.env for plain `node dist/index.js` / `tsx src/index.ts` runs — only
Docker's `env_file` config populated it, by injecting vars before Node even
starts. Every local (non-Docker) run silently had every setting at its
insecure default. Added server/src/loadEnv.ts (dotenv, pointed at the
repo-root .env) as the first import in both server/src/index.ts and
server/src/db/migrate.ts's standalone entrypoint.

Verified against the user's real, reachable services — not mocks:
- Authentik (auth.labsconnect.se): full OIDC login completed by the user
  through the real UI; confirmed their account landed as admin (first user).
- Gitea (gitea.labsconnect.se): the compiled adapter run directly against a
  real API token correctly listed all 11 real repos; a full HTTP-layer test
  against the live server (8 checks) additionally covered a real
  test-connection ping, credential non-leakage in list responses, and role
  gating (403) on the rerun-failed-jobs action even with a valid token
  behind it. None of the real repos have any workflow run history yet, so
  the success/failure status badge and the rerun action itself are
  implemented per the swagger spec but not yet exercised against a real run
  — worth checking once one of those repos has actual CI activity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-14 23:55:41 +02:00
1 parent 069225c656
commit 79710aa7a5
12 files changed
+479 -6

No files matched your search

+32
View File
@@ -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<void>(
`/api/integrations/${integrationId}/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/runs/${runId}/rerun-failed`,
{ method: "POST" },
),
},
},
};
+35 -2
View File
@@ -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<IntegrationSummary[] | null>(null);
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(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 (
<>
<div className="row row-cards mb-3">
@@ -46,7 +69,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
<div className="row row-cards">
{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 (
<div className="col-sm-6 col-lg-4" key={type}>
@@ -64,7 +89,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
)}
</div>
</div>
{isLive ? (
{isLiveTailscale ? (
<div className="mt-2">
<div className="h3 mb-0">
{tailscaleSummary!.online}/{tailscaleSummary!.total}
@@ -74,6 +99,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
<div className="text-yellow small mt-1">{tailscaleSummary!.unauthorized} awaiting authorization</div>
)}
</div>
) : isLiveGitea ? (
<div className="mt-2">
<div className="h3 mb-0">{giteaSummary!.repoCount}</div>
<div className="text-secondary">repositories</div>
{giteaSummary!.failing > 0 && (
<div className="text-red small mt-1">{giteaSummary!.failing} with a failing build</div>
)}
</div>
) : (
<div className="text-secondary mt-2">
{integration ? (
+130
View File
@@ -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 <span className="badge bg-secondary-lt text-secondary">No runs</span>;
const state = run.conclusion || run.status;
switch (state) {
case "success":
return <span className="badge bg-green-lt text-green">Success</span>;
case "failure":
return <span className="badge bg-red-lt text-red">Failed</span>;
case "cancelled":
return <span className="badge bg-secondary-lt text-secondary">Cancelled</span>;
case "skipped":
return <span className="badge bg-secondary-lt text-secondary">Skipped</span>;
case "waiting":
case "queued":
case "pending":
return <span className="badge bg-yellow-lt text-yellow">Queued</span>;
case "running":
case "in_progress":
return <span className="badge bg-blue-lt text-blue">Running</span>;
default:
return <span className="badge bg-secondary-lt text-secondary">{state}</span>;
}
}
const TYPE_LABELS: Record<IntegrationType, string> = {
tailscale: "Tailscale",
proxmox: "Proxmox",
@@ -31,6 +57,11 @@ export default function Integrations({ user }: { user: CurrentUser }) {
const [tailscaleError, setTailscaleError] = useState<string | null>(null);
const [loadingDevices, setLoadingDevices] = useState(false);
const [giteaRepos, setGiteaRepos] = useState<GiteaRepo[] | null>(null);
const [giteaError, setGiteaError] = useState<string | null>(null);
const [loadingRepos, setLoadingRepos] = useState(false);
const [rerunningRun, setRerunningRun] = useState<number | null>(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 (
<>
<div className="d-flex align-items-center mb-3">
@@ -276,6 +336,76 @@ export default function Integrations({ user }: { user: CurrentUser }) {
</table>
</div>
</div>
) : selected?.type === "gitea" ? (
<div className="card">
<div className="card-header">
<h3 className="card-title">Repositories</h3>
<div className="card-actions">
<button className="btn btn-sm btn-outline-secondary" onClick={() => loadGiteaRepos(selected.id)} disabled={loadingRepos}>
{loadingRepos ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{giteaError && <div className="alert alert-danger m-3 mb-0">{giteaError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Repository</th>
<th>Last run</th>
<th>Branch</th>
<th>Status</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{giteaRepos?.map((r) => (
<tr key={r.fullName}>
<td>
<a href={r.htmlUrl} target="_blank" rel="noopener noreferrer">
{r.fullName}
</a>
{r.private && <span className="badge bg-secondary-lt ms-2">Private</span>}
</td>
<td className="text-secondary">
{r.latestRun ? (
<a href={r.latestRun.htmlUrl} target="_blank" rel="noopener noreferrer">
#{r.latestRun.runNumber} {r.latestRun.displayTitle}
</a>
) : r.hasActions ? (
"—"
) : (
"Actions disabled"
)}
</td>
<td className="text-secondary">{r.latestRun?.headBranch ?? "—"}</td>
<td>{runStatusBadge(r)}</td>
{canEdit && (
<td>
{r.latestRun && (r.latestRun.conclusion === "failure" || r.latestRun.status === "failure") && (
<button
className="btn btn-sm"
onClick={() => rerunFailed(r)}
disabled={rerunningRun === r.latestRun.id}
>
{rerunningRun === r.latestRun.id ? "Re-running…" : "Re-run failed"}
</button>
)}
</td>
)}
</tr>
))}
{giteaRepos?.length === 0 && (
<tr>
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
No repositories visible to this token.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
) : (
<div className="card">
<div className="card-body text-secondary">