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:
1 parent
1504c19bbd
commit
abb5335a52
7 files changed
+419
-1
No files matched your search
@@ -17,6 +17,7 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
|
||||
import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
|
||||
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
|
||||
import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const integrationsRouter = Router();
|
||||
@@ -469,3 +470,71 @@ for (const action of dockhandActions) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Semaphore ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function requireSemaphoreAdapter(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 !== "semaphore") {
|
||||
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: createSemaphoreAdapter(loaded.config as any) };
|
||||
}
|
||||
|
||||
integrationsRouter.get("/:id/semaphore/templates", asyncHandler(async (req, res) => {
|
||||
const found = await requireSemaphoreAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
try {
|
||||
const templates = await found.adapter.listTemplatesWithStatus();
|
||||
res.json({
|
||||
templates,
|
||||
summary: {
|
||||
total: templates.length,
|
||||
failing: templates.filter((t) => t.lastTask?.status === "error").length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}));
|
||||
|
||||
integrationsRouter.post(
|
||||
"/:id/semaphore/projects/:projectId/templates/:templateId/run",
|
||||
requireRole("operator"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const found = await requireSemaphoreAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
const projectId = Number(req.params.projectId);
|
||||
const templateId = Number(req.params.templateId);
|
||||
if (!Number.isInteger(projectId) || !Number.isInteger(templateId)) {
|
||||
return res.status(400).json({ error: "invalid_id" });
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await found.adapter.runTemplate(projectId, templateId);
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "integration",
|
||||
action: "run_template",
|
||||
targetType: "semaphore_template",
|
||||
targetId: templateId,
|
||||
detail: { integrationId: found.integration.id, projectId, taskId: task.id },
|
||||
});
|
||||
res.status(201).json({ task });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in new issue
Block a user