Initial commit: SDLC Orchestrator replacing n8n

Lightweight Hono webhook router that receives Gitea events and creates
Coder workspaces for each SDLC stage (analyse, architect, develop,
review, test, rework, release, maintenance). Includes k8s manifests
for deployment and the Coder workspace template moved from babble.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel Samson
2026-04-12 12:44:28 +01:00
co-authored by Claude Opus 4.6
commit 55dee1f2bd
22 changed files with 3521 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
export interface Config {
/** Port for the HTTP server */
port: number;
/** Coder API base URL (e.g. https://coder.samson.media) */
coderUrl: string;
/** Coder API session token */
coderToken: string;
/** Coder template ID for workspaces */
coderTemplateId: string;
/** Gitea API token for the dev bot account */
giteaDevToken: string;
/** Gitea API token for the review bot account */
giteaReviewToken: string;
/** Anthropic API key passed to Claude Code in workspaces */
anthropicApiKey: string;
/** Username of the dev bot (to filter out self-replies) */
botDevUsername: string;
/** Username of the review bot (to filter out self-replies) */
botReviewUsername: string;
/** Gitea base URL (e.g. https://gitea.samson.media) */
giteaUrl: string;
/** Optional webhook secret for verifying Gitea signatures */
webhookSecret?: string;
}
function required(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export function loadConfig(): Config {
return {
port: parseInt(process.env.PORT || "3000", 10),
coderUrl: required("CODER_URL"),
coderToken: required("CODER_TOKEN"),
coderTemplateId: required("CODER_TEMPLATE_ID"),
giteaDevToken: required("GITEA_DEV_TOKEN"),
giteaReviewToken: required("GITEA_REVIEW_TOKEN"),
anthropicApiKey: required("ANTHROPIC_API_KEY"),
botDevUsername: process.env.BOT_DEV_USERNAME || "claude-dev",
botReviewUsername: process.env.BOT_REVIEW_USERNAME || "claude-review",
giteaUrl: process.env.GITEA_URL || "https://gitea.samson.media",
webhookSecret: process.env.WEBHOOK_SECRET,
};
}
+53
View File
@@ -0,0 +1,53 @@
import type { Context } from "hono";
import type { IssueCommentEvent, TaskRequest } from "../types.js";
import type { CoderClient } from "../services/coder.js";
import type { Config } from "../config.js";
/**
* Issue comment created → re-trigger analyst if still in analysis.
*
* Replaces: issue-comment-reply.json
*/
export function issueComment(config: Config, coder: CoderClient) {
return async (c: Context) => {
const event = await c.req.json<IssueCommentEvent>();
if (event.action !== "created") {
return c.text("ignored: not a created event", 200);
}
const commenter = event.comment.user.login;
const botUsernames = [config.botDevUsername, config.botReviewUsername];
if (botUsernames.includes(commenter)) {
return c.text("ignored: bot comment", 200);
}
const labels = event.issue.labels.map((l) => l.name);
const pastAnalysis =
labels.includes("ready-for-architecture") ||
labels.includes("ready-for-development");
if (pastAnalysis) {
return c.text("ignored: issue past analysis stage", 200);
}
const [org, repo] = event.repository.full_name.split("/");
const task: TaskRequest = {
taskType: "analyse",
issueNumber: event.issue.number,
giteaOrg: org,
giteaRepo: repo,
repoCloneUrl: event.repository.clone_url,
giteaToken: config.giteaDevToken,
};
console.log(`[issue-comment] #${task.issueNumber} → re-trigger analyst`);
const workspace = await coder.createWorkspace(task);
console.log(`[issue-comment] workspace created: ${workspace.name}`);
return c.json({ ok: true, workspace: workspace.name });
};
}
+72
View File
@@ -0,0 +1,72 @@
import type { Context } from "hono";
import type { IssueEvent, TaskRequest } from "../types.js";
import type { CoderClient } from "../services/coder.js";
import type { GiteaClient } from "../services/gitea.js";
import type { Config } from "../config.js";
const LABEL_TO_TASK: Record<string, string> = {
"ready-for-architecture": "architect",
"ready-for-development": "develop",
"ready-for-testing": "test",
"ready-for-deployment": "devops",
};
/** Tasks that should use the review bot account. */
const REVIEW_TASKS = new Set(["test"]);
/**
* Issue labeled → SDLC stage transition.
*
* Replaces: issue-stage-transition.json
*/
export function issueLabel(config: Config, coder: CoderClient, gitea: GiteaClient) {
return async (c: Context) => {
const event = await c.req.json<IssueEvent>();
if (event.action !== "label_updated" && event.action !== "labeled") {
return c.text("ignored: not a label event", 200);
}
const labels = event.issue.labels.map((l) => l.name);
const [org, repo] = event.repository.full_name.split("/");
let taskType: string | null = null;
for (const [label, task] of Object.entries(LABEL_TO_TASK)) {
if (labels.includes(label)) {
taskType = task;
break;
}
}
if (!taskType) {
return c.text("ignored: no matching stage label", 200);
}
const giteaToken = REVIEW_TASKS.has(taskType)
? config.giteaReviewToken
: config.giteaDevToken;
const task: TaskRequest = {
taskType,
issueNumber: event.issue.number,
giteaOrg: org,
giteaRepo: repo,
repoCloneUrl: event.repository.clone_url,
giteaToken,
};
console.log(`[issue-label] #${task.issueNumber} → ${taskType}`);
const workspace = await coder.createWorkspace(task);
console.log(`[issue-label] workspace created: ${workspace.name}`);
await gitea.commentOnIssue(
org,
repo,
event.issue.number,
`🤖 SDLC stage transition: **${taskType}** — workspace created.`,
);
return c.json({ ok: true, workspace: workspace.name });
};
}
+49
View File
@@ -0,0 +1,49 @@
import type { Context } from "hono";
import type { IssueEvent, TaskRequest } from "../types.js";
import type { CoderClient } from "../services/coder.js";
import type { GiteaClient } from "../services/gitea.js";
import type { Config } from "../config.js";
/**
* Issue opened → route to analyst or fix-bug.
*
* Replaces: gitea-issue-triage.json
*/
export function issueTriage(config: Config, coder: CoderClient, gitea: GiteaClient) {
return async (c: Context) => {
const event = await c.req.json<IssueEvent>();
if (event.action !== "opened") {
return c.text("ignored: not an open event", 200);
}
const labels = event.issue.labels.map((l) => l.name);
const [org, repo] = event.repository.full_name.split("/");
const isBug = labels.includes("bug");
const taskType = isBug ? "fix-bug" : "analyse";
const task: TaskRequest = {
taskType,
issueNumber: event.issue.number,
giteaOrg: org,
giteaRepo: repo,
repoCloneUrl: event.repository.clone_url,
giteaToken: config.giteaDevToken,
};
console.log(`[issue-triage] #${task.issueNumber} → ${taskType}`);
const workspace = await coder.createWorkspace(task);
console.log(`[issue-triage] workspace created: ${workspace.name}`);
await gitea.commentOnIssue(
org,
repo,
event.issue.number,
`🤖 Issue received. Starting **${taskType}** stage.`,
);
return c.json({ ok: true, workspace: workspace.name });
};
}
+45
View File
@@ -0,0 +1,45 @@
import type { CoderClient } from "../services/coder.js";
import type { Config } from "../config.js";
import type { TaskRequest } from "../types.js";
export interface MaintenanceRepo {
cloneUrl: string;
org: string;
repo: string;
}
/**
* Weekly maintenance cron → create a maintenance workspace.
*
* Replaces: scheduled-maintenance.json
*/
export function runMaintenance(
config: Config,
coder: CoderClient,
repos: MaintenanceRepo[],
) {
return async () => {
for (const entry of repos) {
const task: TaskRequest = {
taskType: "maintenance",
issueNumber: 0,
giteaOrg: entry.org,
giteaRepo: entry.repo,
repoCloneUrl: entry.cloneUrl,
giteaToken: config.giteaDevToken,
};
try {
const workspace = await coder.createWorkspace(task);
console.log(
`[maintenance] ${entry.org}/${entry.repo} → workspace: ${workspace.name}`,
);
} catch (err) {
console.error(
`[maintenance] failed for ${entry.org}/${entry.repo}:`,
err,
);
}
}
};
}
+50
View File
@@ -0,0 +1,50 @@
import type { Context } from "hono";
import type { PullRequestEvent, TaskRequest } from "../types.js";
import type { CoderClient } from "../services/coder.js";
import type { GiteaClient } from "../services/gitea.js";
import type { Config } from "../config.js";
/**
* PR opened or synchronized → trigger Claude Code review.
* Skips if PR already has an approval (prevents test→review→test loops).
*
* Replaces: pr-review-trigger.json
*/
export function prReview(config: Config, coder: CoderClient, gitea: GiteaClient) {
return async (c: Context) => {
const event = await c.req.json<PullRequestEvent>();
if (event.action !== "opened" && event.action !== "synchronized") {
return c.text("ignored: not opened or synchronized", 200);
}
const [org, repo] = event.repository.full_name.split("/");
const prNumber = event.pull_request.number;
// On new commits, skip review if already approved
if (event.action === "synchronized") {
const reviews = await gitea.getReviews(org, repo, prNumber);
const hasApproval = reviews.some((r) => r.state === "APPROVED");
if (hasApproval) {
console.log(`[pr-review] PR #${prNumber} already approved, skipping`);
return c.text("ignored: already approved", 200);
}
}
const task: TaskRequest = {
taskType: "review-code",
issueNumber: prNumber,
giteaOrg: org,
giteaRepo: repo,
repoCloneUrl: event.repository.clone_url,
giteaToken: config.giteaReviewToken,
};
console.log(`[pr-review] PR #${prNumber} → review-code`);
const workspace = await coder.createWorkspace(task);
console.log(`[pr-review] workspace created: ${workspace.name}`);
return c.json({ ok: true, workspace: workspace.name });
};
}
+60
View File
@@ -0,0 +1,60 @@
import type { Context } from "hono";
import type { PullRequestReviewEvent, TaskRequest } from "../types.js";
import type { CoderClient } from "../services/coder.js";
import type { GiteaClient } from "../services/gitea.js";
import type { Config } from "../config.js";
/**
* PR review submitted → rework on REQUEST_CHANGES, test on APPROVED.
*
* Replaces: pr-review-rework.json
*/
export function prRework(config: Config, coder: CoderClient, gitea: GiteaClient) {
return async (c: Context) => {
const event = await c.req.json<PullRequestReviewEvent>();
if (event.action !== "reviewed") {
return c.text("ignored: not a reviewed event", 200);
}
const [org, repo] = event.repository.full_name.split("/");
const prNumber = event.pull_request.number;
const reviewState = event.review.state.toLowerCase();
let taskType: string | null = null;
let giteaToken: string;
if (reviewState === "request_changes") {
taskType = "rework-pr";
giteaToken = config.giteaDevToken;
} else if (reviewState === "approved") {
taskType = "test";
giteaToken = config.giteaReviewToken;
} else {
return c.text("ignored: review state not actionable", 200);
}
const task: TaskRequest = {
taskType,
issueNumber: prNumber,
giteaOrg: org,
giteaRepo: repo,
repoCloneUrl: event.repository.clone_url,
giteaToken,
};
console.log(`[pr-rework] PR #${prNumber} ${reviewState} → ${taskType}`);
const workspace = await coder.createWorkspace(task);
console.log(`[pr-rework] workspace created: ${workspace.name}`);
await gitea.commentOnIssue(
org,
repo,
prNumber,
`🤖 Review outcome: starting **${taskType}** stage.`,
);
return c.json({ ok: true, workspace: workspace.name });
};
}
+69
View File
@@ -0,0 +1,69 @@
import type { Context } from "hono";
import type { IssueEvent, TaskRequest } from "../types.js";
import type { CoderClient } from "../services/coder.js";
import type { GiteaClient } from "../services/gitea.js";
import type { Config } from "../config.js";
/**
* Release ticket opened → tag RC for staging.
* Release ticket labeled staging-verified → tag for production.
*
* Replaces: release-process.json
*/
export function release(config: Config, coder: CoderClient, gitea: GiteaClient) {
return async (c: Context) => {
const event = await c.req.json<IssueEvent>();
const labels = event.issue.labels.map((l) => l.name);
const [org, repo] = event.repository.full_name.split("/");
const isRelease =
event.issue.title.toLowerCase().includes("release") ||
labels.includes("release");
if (!isRelease) {
return c.text("ignored: not a release ticket", 200);
}
let shouldProcess = false;
if (event.action === "opened") {
shouldProcess = true;
}
if (
(event.action === "label_updated" || event.action === "labeled") &&
labels.includes("staging-verified") &&
!labels.includes("deployed-to-production")
) {
shouldProcess = true;
}
if (!shouldProcess) {
return c.text("ignored: no actionable release event", 200);
}
const task: TaskRequest = {
taskType: "release",
issueNumber: event.issue.number,
giteaOrg: org,
giteaRepo: repo,
repoCloneUrl: event.repository.clone_url,
giteaToken: config.giteaDevToken,
};
console.log(`[release] #${task.issueNumber} → release`);
const workspace = await coder.createWorkspace(task);
console.log(`[release] workspace created: ${workspace.name}`);
await gitea.commentOnIssue(
org,
repo,
event.issue.number,
`🤖 Release process started.`,
);
return c.json({ ok: true, workspace: workspace.name });
};
}
+93
View File
@@ -0,0 +1,93 @@
import { Hono } from "hono";
import { logger } from "hono/logger";
import { serve } from "@hono/node-server";
import cron from "node-cron";
import { loadConfig } from "./config.js";
import { CoderClient } from "./services/coder.js";
import { GiteaClient } from "./services/gitea.js";
import { issueTriage } from "./handlers/issue-triage.js";
import { issueLabel } from "./handlers/issue-label.js";
import { issueComment } from "./handlers/issue-comment.js";
import { prReview } from "./handlers/pr-review.js";
import { prRework } from "./handlers/pr-rework.js";
import { release } from "./handlers/release.js";
import { runMaintenance, type MaintenanceRepo } from "./handlers/maintenance.js";
// ---------------------------------------------------------------------------
// Bootstrap
// ---------------------------------------------------------------------------
const config = loadConfig();
const coderClient = new CoderClient(config);
const giteaClient = new GiteaClient(config);
const app = new Hono();
app.use("*", logger());
// ---------------------------------------------------------------------------
// Health check
// ---------------------------------------------------------------------------
app.get("/health", (c) => c.json({ status: "ok" }));
// ---------------------------------------------------------------------------
// Webhook endpoints — same paths as the n8n webhooks so Gitea config
// can stay unchanged (just point to new host).
// ---------------------------------------------------------------------------
app.post("/webhook/gitea-issue-triage", issueTriage(config, coderClient, giteaClient));
app.post("/webhook/gitea-issue-label", issueLabel(config, coderClient, giteaClient));
app.post("/webhook/gitea-issue-comment", issueComment(config, coderClient));
app.post("/webhook/gitea-pr-review", prReview(config, coderClient, giteaClient));
app.post("/webhook/gitea-pr-review-rework", prRework(config, coderClient, giteaClient));
app.post("/webhook/gitea-release", release(config, coderClient, giteaClient));
// ---------------------------------------------------------------------------
// Scheduled maintenance cron (Monday 9:00 AM)
// ---------------------------------------------------------------------------
const maintenanceRepos = parseMaintenanceRepos();
if (maintenanceRepos.length > 0) {
const maintenanceFn = runMaintenance(config, coderClient, maintenanceRepos);
cron.schedule("0 9 * * 1", () => {
console.log("[cron] running weekly maintenance");
maintenanceFn().catch((err) =>
console.error("[cron] maintenance failed:", err),
);
});
console.log(
`Maintenance cron scheduled for ${maintenanceRepos.length} repo(s)`,
);
}
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
serve({ fetch: app.fetch, port: config.port }, (info) => {
console.log(`SDLC Orchestrator listening on :${info.port}`);
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Parse MAINTENANCE_REPOS env var.
* Format: comma-separated "org/repo=clone_url" entries.
* Example: "claude/babble=https://gitea.samson.media/claude/babble.git"
*/
function parseMaintenanceRepos(): MaintenanceRepo[] {
const raw = process.env.MAINTENANCE_REPOS;
if (!raw) return [];
return raw.split(",").map((entry) => {
const [fullName, cloneUrl] = entry.trim().split("=");
const [org, repo] = fullName.split("/");
return { org, repo, cloneUrl };
});
}
+61
View File
@@ -0,0 +1,61 @@
import type { Config } from "../config.js";
import type { TaskRequest } from "../types.js";
export class CoderClient {
private baseUrl: string;
private token: string;
private templateId: string;
private anthropicApiKey: string;
constructor(config: Config) {
this.baseUrl = config.coderUrl.replace(/\/$/, "");
this.token = config.coderToken;
this.templateId = config.coderTemplateId;
this.anthropicApiKey = config.anthropicApiKey;
}
async createWorkspace(task: TaskRequest): Promise<{ id: string; name: string }> {
const timestamp = new Date()
.toISOString()
.replace(/[-:T]/g, "")
.slice(8, 14); // HHmmss
const name = `${task.taskType}-${task.issueNumber}-${timestamp}`;
const body = {
name,
template_id: this.templateId,
rich_parameter_values: [
{ name: "issue_number", value: String(task.issueNumber) },
{ name: "task_type", value: task.taskType },
{ name: "repo_clone_url", value: task.repoCloneUrl },
{ name: "gitea_token", value: task.giteaToken },
{ name: "gitea_org", value: task.giteaOrg },
{ name: "gitea_repo", value: task.giteaRepo },
{ name: "anthropic_api_key", value: this.anthropicApiKey },
...(task.deployEnv
? [{ name: "deploy_env", value: task.deployEnv }]
: []),
],
};
const res = await fetch(
`${this.baseUrl}/api/v2/organizations/default/members/me/workspaces`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Coder-Session-Token": this.token,
},
body: JSON.stringify(body),
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(`Coder API error ${res.status}: ${text}`);
}
const data = (await res.json()) as { id: string; name: string };
return { id: data.id, name: data.name };
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { Config } from "../config.js";
export class GiteaClient {
private baseUrl: string;
private devToken: string;
private reviewToken: string;
constructor(config: Config) {
this.baseUrl = config.giteaUrl.replace(/\/$/, "");
this.devToken = config.giteaDevToken;
this.reviewToken = config.giteaReviewToken;
}
async commentOnIssue(
org: string,
repo: string,
issueNumber: number,
body: string,
useReviewAccount = false,
): Promise<void> {
const token = useReviewAccount ? this.reviewToken : this.devToken;
const res = await fetch(
`${this.baseUrl}/api/v1/repos/${org}/${repo}/issues/${issueNumber}/comments`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `token ${token}`,
},
body: JSON.stringify({ body }),
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(`Gitea comment API error ${res.status}: ${text}`);
}
}
async getReviews(
org: string,
repo: string,
prNumber: number,
): Promise<Array<{ state: string }>> {
const res = await fetch(
`${this.baseUrl}/api/v1/repos/${org}/${repo}/pulls/${prNumber}/reviews`,
{
headers: {
Authorization: `token ${this.reviewToken}`,
},
},
);
if (!res.ok) {
return [];
}
return (await res.json()) as Array<{ state: string }>;
}
async removeLabel(
org: string,
repo: string,
issueNumber: number,
labelId: number,
): Promise<void> {
const res = await fetch(
`${this.baseUrl}/api/v1/repos/${org}/${repo}/issues/${issueNumber}/labels/${labelId}`,
{
method: "DELETE",
headers: {
Authorization: `token ${this.devToken}`,
},
},
);
if (!res.ok) {
const text = await res.text();
console.warn(`Failed to remove label ${labelId}: ${res.status} ${text}`);
}
}
}
+102
View File
@@ -0,0 +1,102 @@
// ---------------------------------------------------------------------------
// Gitea webhook payload types
// ---------------------------------------------------------------------------
export interface GiteaUser {
id: number;
login: string;
full_name: string;
email: string;
}
export interface GiteaLabel {
id: number;
name: string;
color: string;
}
export interface GiteaRepository {
id: number;
name: string;
full_name: string; // "org/repo"
clone_url: string;
}
export interface GiteaIssue {
id: number;
number: number;
title: string;
body: string;
state: string;
user: GiteaUser;
labels: GiteaLabel[];
}
export interface GiteaPullRequest {
id: number;
number: number;
title: string;
body: string;
state: string;
user: GiteaUser;
head: { ref: string; sha: string };
base: { ref: string };
}
export interface GiteaReview {
id: number;
body: string;
state: string; // "approved", "request_changes", "comment"
user: GiteaUser;
}
export interface GiteaComment {
id: number;
body: string;
user: GiteaUser;
}
// ---------------------------------------------------------------------------
// Webhook event payloads
// ---------------------------------------------------------------------------
export interface IssueEvent {
action: string; // "opened", "labeled", "label_updated", etc.
issue: GiteaIssue;
repository: GiteaRepository;
label?: GiteaLabel;
}
export interface PullRequestEvent {
action: string; // "opened", "synchronized", "closed"
pull_request: GiteaPullRequest;
repository: GiteaRepository;
}
export interface PullRequestReviewEvent {
action: string; // "reviewed"
review: GiteaReview;
pull_request: GiteaPullRequest;
repository: GiteaRepository;
}
export interface IssueCommentEvent {
action: string; // "created"
comment: GiteaComment;
issue: GiteaIssue;
repository: GiteaRepository;
}
// ---------------------------------------------------------------------------
// Internal task type
// ---------------------------------------------------------------------------
export interface TaskRequest {
taskType: string;
issueNumber: number;
giteaOrg: string;
giteaRepo: string;
repoCloneUrl: string;
giteaToken: string;
deployEnv?: string;
}