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
Generated
+13
@@ -2426,6 +2426,18 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/drizzle-kit": {
|
||||||
"version": "0.31.10",
|
"version": "0.31.10",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
||||||
@@ -4809,6 +4821,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.14.0",
|
"@libsql/client": "^0.14.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.14.0",
|
"@libsql/client": "^0.14.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "../loadEnv.js";
|
||||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "./loadEnv.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import FileStoreFactory from "session-file-store";
|
import FileStoreFactory from "session-file-store";
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
|
|||||||
},
|
},
|
||||||
{ key: "apiKey", label: "API key", secret: true, type: "password" },
|
{ 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. */
|
/** 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",
|
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(
|
export function splitIntegrationConfig(
|
||||||
type: IntegrationType,
|
type: IntegrationType,
|
||||||
input: Record<string, string | boolean>,
|
input: Record<string, string | boolean>,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
import type { IntegrationType } from "../db/schema.js";
|
import type { IntegrationType } from "../db/schema.js";
|
||||||
import type { IntegrationConfig } from "./types.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) {
|
switch (type) {
|
||||||
case "tailscale":
|
case "tailscale":
|
||||||
return createTailscaleAdapter(config as any);
|
return createTailscaleAdapter(config as any);
|
||||||
|
case "gitea":
|
||||||
|
return createGiteaAdapter(config as any);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Integration type "${type}" is not implemented yet`);
|
throw new Error(`Integration type "${type}" is not implemented yet`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") });
|
||||||
@@ -8,13 +8,14 @@ import { recordAudit } from "../services/audit.js";
|
|||||||
import { encryptSecret } from "../crypto.js";
|
import { encryptSecret } from "../crypto.js";
|
||||||
import {
|
import {
|
||||||
INTEGRATION_FIELDS,
|
INTEGRATION_FIELDS,
|
||||||
INTEGRATION_BASE_URLS,
|
resolveBaseUrl,
|
||||||
splitIntegrationConfig,
|
splitIntegrationConfig,
|
||||||
validateIntegrationConfig,
|
validateIntegrationConfig,
|
||||||
} from "../integrations/fieldSchemas.js";
|
} from "../integrations/fieldSchemas.js";
|
||||||
import { createIntegrationAdapter } from "../integrations/registry.js";
|
import { createIntegrationAdapter } from "../integrations/registry.js";
|
||||||
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||||
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
|
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
|
||||||
|
import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
|
||||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||||
|
|
||||||
export const integrationsRouter = Router();
|
export const integrationsRouter = Router();
|
||||||
@@ -81,7 +82,7 @@ integrationsRouter.post("/", requireRole("admin"), asyncHandler(async (req, res)
|
|||||||
.values({
|
.values({
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
baseUrl: INTEGRATION_BASE_URLS[type] ?? "",
|
baseUrl: resolveBaseUrl(type, nonSecretFields),
|
||||||
credentialId,
|
credentialId,
|
||||||
config: JSON.stringify(nonSecretFields),
|
config: JSON.stringify(nonSecretFields),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -129,6 +130,7 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
|
|||||||
|
|
||||||
let credentialId = existing.credentialId;
|
let credentialId = existing.credentialId;
|
||||||
let configJson = existing.config;
|
let configJson = existing.config;
|
||||||
|
let baseUrl = existing.baseUrl;
|
||||||
|
|
||||||
if (parsed.data.config) {
|
if (parsed.data.config) {
|
||||||
const loaded = await loadIntegrationConfig(id);
|
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];
|
if (field.secret) delete (mergedNonSecret as Record<string, unknown>)[field.key];
|
||||||
}
|
}
|
||||||
configJson = JSON.stringify(mergedNonSecret);
|
configJson = JSON.stringify(mergedNonSecret);
|
||||||
|
baseUrl = resolveBaseUrl(existing.type, mergedNonSecret);
|
||||||
|
|
||||||
if (Object.keys(secretFields).length > 0) {
|
if (Object.keys(secretFields).length > 0) {
|
||||||
const existingSecrets: Record<string, string | boolean> = {};
|
const existingSecrets: Record<string, string | boolean> = {};
|
||||||
@@ -166,6 +169,7 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
|
|||||||
enabled: parsed.data.enabled ?? existing.enabled,
|
enabled: parsed.data.enabled ?? existing.enabled,
|
||||||
credentialId,
|
credentialId,
|
||||||
config: configJson,
|
config: configJson,
|
||||||
|
baseUrl,
|
||||||
})
|
})
|
||||||
.where(eq(integrations.id, id))
|
.where(eq(integrations.id, id))
|
||||||
.returning();
|
.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) });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -198,6 +198,30 @@ export interface TailscaleDevicesResponse {
|
|||||||
summary: { total: number; online: number; unauthorized: number };
|
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 UnauthorizedError extends Error {}
|
||||||
export class ForbiddenError extends Error {}
|
export class ForbiddenError extends Error {}
|
||||||
|
|
||||||
@@ -368,5 +392,13 @@ export const api = {
|
|||||||
method: "DELETE",
|
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" },
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -11,9 +11,15 @@ const WIDGETS: { type: IntegrationType; label: string }[] = [
|
|||||||
{ type: "dockhand", label: "Dockhand / Docker" },
|
{ type: "dockhand", label: "Dockhand / Docker" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface GiteaSummary {
|
||||||
|
repoCount: number;
|
||||||
|
failing: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard({ user }: { user: CurrentUser }) {
|
export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||||
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
|
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
|
||||||
|
const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.integrations.list().then((res) => setIntegrations(res.integrations));
|
api.integrations.list().then((res) => setIntegrations(res.integrations));
|
||||||
@@ -31,6 +37,23 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
.catch(() => setTailscaleSummary(null));
|
.catch(() => setTailscaleSummary(null));
|
||||||
}, [integrations]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="row row-cards mb-3">
|
<div className="row row-cards mb-3">
|
||||||
@@ -46,7 +69,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
<div className="row row-cards">
|
<div className="row row-cards">
|
||||||
{WIDGETS.map(({ type, label }) => {
|
{WIDGETS.map(({ type, label }) => {
|
||||||
const integration = integrations?.find((i) => i.type === type && i.enabled);
|
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 (
|
return (
|
||||||
<div className="col-sm-6 col-lg-4" key={type}>
|
<div className="col-sm-6 col-lg-4" key={type}>
|
||||||
@@ -64,7 +89,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isLive ? (
|
{isLiveTailscale ? (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="h3 mb-0">
|
<div className="h3 mb-0">
|
||||||
{tailscaleSummary!.online}/{tailscaleSummary!.total}
|
{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 className="text-yellow small mt-1">{tailscaleSummary!.unauthorized} awaiting authorization</div>
|
||||||
)}
|
)}
|
||||||
</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">
|
<div className="text-secondary mt-2">
|
||||||
{integration ? (
|
{integration ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type CurrentUser,
|
type CurrentUser,
|
||||||
|
type GiteaRepo,
|
||||||
type IntegrationSummary,
|
type IntegrationSummary,
|
||||||
type IntegrationType,
|
type IntegrationType,
|
||||||
type TailscaleDevice,
|
type TailscaleDevice,
|
||||||
@@ -9,6 +10,31 @@ import {
|
|||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import IntegrationForm from "../components/IntegrationForm";
|
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> = {
|
const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||||
tailscale: "Tailscale",
|
tailscale: "Tailscale",
|
||||||
proxmox: "Proxmox",
|
proxmox: "Proxmox",
|
||||||
@@ -31,6 +57,11 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
const [tailscaleError, setTailscaleError] = useState<string | null>(null);
|
const [tailscaleError, setTailscaleError] = useState<string | null>(null);
|
||||||
const [loadingDevices, setLoadingDevices] = useState(false);
|
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() {
|
function loadIntegrations() {
|
||||||
api.integrations
|
api.integrations
|
||||||
.list()
|
.list()
|
||||||
@@ -55,12 +86,27 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
.finally(() => setLoadingDevices(false));
|
.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(() => {
|
useEffect(() => {
|
||||||
if (selected?.type === "tailscale" && selected.enabled && !managing) {
|
if (selected?.type === "tailscale" && selected.enabled && !managing) {
|
||||||
loadTailscaleDevices(selected.id);
|
loadTailscaleDevices(selected.id);
|
||||||
} else {
|
} else {
|
||||||
setTailscaleData(null);
|
setTailscaleData(null);
|
||||||
}
|
}
|
||||||
|
if (selected?.type === "gitea" && selected.enabled && !managing) {
|
||||||
|
loadGiteaRepos(selected.id);
|
||||||
|
} else {
|
||||||
|
setGiteaRepos(null);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedId, managing, integrations]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="d-flex align-items-center mb-3">
|
<div className="d-flex align-items-center mb-3">
|
||||||
@@ -276,6 +336,76 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div className="card-body text-secondary">
|
<div className="card-body text-secondary">
|
||||||
|
|||||||
Reference in new issue
Block a user