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:
1 parent
069225c656
commit
79710aa7a5
12 files changed
+479
-6
No files matched your search
@@ -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) });
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in new issue
Block a user