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
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user