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

+18
View File
@@ -15,6 +15,10 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
},
{ key: "apiKey", label: "API key", secret: true, type: "password" },
],
gitea: [
{ key: "url", label: "Gitea URL", secret: false, placeholder: "https://gitea.example.lan" },
{ key: "token", label: "API token", secret: true, type: "password" },
],
};
/** Fixed base URL per integration type, stored on the row for display/reference. */
@@ -22,6 +26,20 @@ export const INTEGRATION_BASE_URLS: Partial<Record<IntegrationType, string>> = {
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, string | boolean | undefined>,
): string {
const url = nonSecretFields.url;
if (typeof url === "string" && url) return url;
return INTEGRATION_BASE_URLS[type] ?? "";
}
export function splitIntegrationConfig(
type: IntegrationType,
input: Record<string, string | boolean>,
+154
View File
@@ -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<GiteaRepoWithStatus[]>;
rerunFailedJobs(owner: string, repo: string, runId: number): Promise<void>;
}
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<any> {
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<GiteaRepo[]> {
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<GiteaWorkflowRun | null> {
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<GiteaRepoWithStatus[]> {
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<void> {
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 };
}
+16 -2
View File
@@ -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`);
}