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

+67 -2
View File
@@ -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<string, unknown>)[field.key];
}
configJson = JSON.stringify(mergedNonSecret);
baseUrl = resolveBaseUrl(existing.type, mergedNonSecret);
if (Object.keys(secretFields).length > 0) {
const existingSecrets: Record<string, string | boolean> = {};
@@ -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) });
}
}),
);