Add Semaphore integration

Fourth live integration: Ansible run history per template with a
trigger-a-run action — matching the "dashboard + basic actions" depth from
the plan. Adapter built against Semaphore's official published OpenAPI spec
(github.com/semaphoreui/semaphore/blob/develop/api-docs.yml) plus its Go
source directly for the task-status enum, which the spec itself leaves
untyped (db/Task.go, pkg/task_logger/task_logger.go) — instance wasn't
reachable from here (semaphore.int.toolabs.se doesn't resolve outside the
user's LAN), so verified against the real spec/source rather than guessing.

server/src/integrations/semaphore/adapter.ts: GET /api/projects, then GET
/api/project/{id}/templates?sort=name&order=asc per project — each template
in that response already embeds its last_task, so listing every template's
current status across every project needs only one call per project (no
N+1 per-template lookup, unlike Gitea where the run history isn't embedded
in the repo list). POST /api/project/{id}/tasks {template_id} triggers a
run. Follows the same config-in-UI + encrypted-credential pattern as the
other three integrations.

Verified: full build passes. Same as Dockhand — unreachable, so ran a
14-check HTTP test against the live server (real target URL, bogus token):
role gating, credential non-leakage, disabled-integration blocking, and the
wrong_type crash-safety check for this fourth adapter type, confirming the
server stays up throughout. Real template/run data and the trigger-a-run
action still need verification once this app can reach the user's LAN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 00:14:44 +02:00
1 parent 1504c19bbd
commit abb5335a52
7 files changed
+419 -1

No files matched your search

@@ -0,0 +1,144 @@
/**
* Semaphore (Ansible Semaphore / Semaphore UI) adapter.
* Requires config: url, token
*
* API reference verified against the official spec
* (https://github.com/semaphoreui/semaphore/blob/develop/api-docs.yml) and
* source (db/Task.go, pkg/task_logger/task_logger.go) for the exact task
* status enum, since the swagger doc itself doesn't enumerate it.
*/
export interface SemaphoreConfig {
url: string;
token: string;
}
// waiting -> starting -> running -> (success | error | stopped)
// waiting_confirmation/confirmed/rejected apply only to templates requiring approval.
export type SemaphoreTaskStatus =
| "waiting"
| "starting"
| "waiting_confirmation"
| "confirmed"
| "rejected"
| "running"
| "stopping"
| "stopped"
| "success"
| "error";
export interface SemaphoreTask {
id: number;
status: SemaphoreTaskStatus;
created: string | null;
start: string | null;
end: string | null;
}
export interface SemaphoreTemplate {
id: number;
projectId: number;
projectName: string;
name: string;
playbook: string;
lastTask: SemaphoreTask | null;
}
export interface SemaphoreAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listTemplatesWithStatus(): Promise<SemaphoreTemplate[]>;
runTemplate(projectId: number, templateId: number): Promise<SemaphoreTask>;
}
export function createSemaphoreAdapter(config: SemaphoreConfig): SemaphoreAdapter {
function base() {
return config.url.replace(/\/$/, "");
}
function headers() {
return {
Authorization: `Bearer ${config.token}`,
Accept: "application/json",
"Content-Type": "application/json",
};
}
async function api(method: string, path: string, body?: unknown): Promise<any> {
const res = await fetch(`${base()}/api${path}`, {
method,
headers: headers(),
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return 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((typeof data === "string" ? data : data?.error) || `Semaphore API error: HTTP ${res.status}`);
}
return data;
}
function mapTask(t: any): SemaphoreTask | null {
if (!t) return null;
return {
id: t.id,
status: t.status,
created: t.created ?? null,
start: t.start ?? null,
end: t.end ?? null,
};
}
async function listProjects(): Promise<{ id: number; name: string }[]> {
const data = await api("GET", "/projects");
return (Array.isArray(data) ? data : []).map((p: any) => ({ id: p.id, name: p.name }));
}
async function listTemplatesForProject(projectId: number, projectName: string): Promise<SemaphoreTemplate[]> {
const data = await api("GET", `/project/${projectId}/templates?sort=name&order=asc`);
return (Array.isArray(data) ? data : []).map((t: any) => ({
id: t.id,
projectId,
projectName,
name: t.name,
playbook: t.playbook,
lastTask: mapTask(t.last_task),
}));
}
async function listTemplatesWithStatus(): Promise<SemaphoreTemplate[]> {
const projects = await listProjects();
const perProject = await Promise.all(
projects.map(async (p) => {
try {
return await listTemplatesForProject(p.id, p.name);
} catch {
return [];
}
}),
);
return perProject.flat();
}
async function runTemplate(projectId: number, templateId: number): Promise<SemaphoreTask> {
const task = await api("POST", `/project/${projectId}/tasks`, { template_id: templateId });
return mapTask(task)!;
}
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await api("GET", "/projects");
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return { ping, listTemplatesWithStatus, runTemplate };
}