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
commit 55dee1f2bd
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
22 changed files with 3521 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.env
.dev.vars

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM node:22-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ src/
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist/ dist/
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]

100
README.md Normal file
View File

@ -0,0 +1,100 @@
# SDLC Orchestrator
Lightweight webhook-driven orchestrator that replaces n8n for the Gitea + Coder + Claude Code SDLC pipeline.
Receives Gitea webhooks, routes them to the correct SDLC stage, creates Coder workspaces, and posts status comments back on issues/PRs.
## Architecture
```
Gitea Webhooks → SDLC Orchestrator → Coder API (creates workspace)
→ Gitea API (posts comments)
```
~500 lines of TypeScript. No database, no UI, no state — just a webhook router.
## Webhook Endpoints
| Endpoint | Gitea Event | Action |
|----------|-------------|--------|
| `POST /webhook/gitea-issue-triage` | Issue opened | Route to `analyse` or `fix-bug` |
| `POST /webhook/gitea-issue-label` | Issue labeled | Stage transition (architect/develop/test/devops) |
| `POST /webhook/gitea-issue-comment` | Comment created | Re-trigger analyst if still in analysis |
| `POST /webhook/gitea-pr-review` | PR opened/synced | Trigger code review |
| `POST /webhook/gitea-pr-review-rework` | Review submitted | Route to `rework-pr` or `test` |
| `POST /webhook/gitea-release` | Release issue | Tag RC or production release |
| `GET /health` | — | Health check |
Plus a cron job (Monday 9 AM) for weekly maintenance.
## Setup
### Prerequisites
- Node.js 22+
- Coder running with the `cloudflare-worker` template (see `coder/`)
- Gitea with two bot accounts: `claude-dev` and `claude-review`
### Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `PORT` | No | HTTP port (default: 3000) |
| `CODER_URL` | Yes | Coder API base URL |
| `CODER_TOKEN` | Yes | Coder session token |
| `CODER_TEMPLATE_ID` | Yes | Coder workspace template ID |
| `GITEA_DEV_TOKEN` | Yes | Gitea API token for `claude-dev` |
| `GITEA_REVIEW_TOKEN` | Yes | Gitea API token for `claude-review` |
| `ANTHROPIC_API_KEY` | Yes | Anthropic API key for Claude Code |
| `GITEA_URL` | No | Gitea base URL (default: `https://gitea.samson.media`) |
| `BOT_DEV_USERNAME` | No | Dev bot username (default: `claude-dev`) |
| `BOT_REVIEW_USERNAME` | No | Review bot username (default: `claude-review`) |
| `MAINTENANCE_REPOS` | No | Comma-separated `org/repo=clone_url` for weekly maintenance |
### Local Development
```bash
npm install
cp k8s/secret.yaml.example .env # Edit with real values (use KEY=value format)
npm run dev
```
### Deploy to k3s
```bash
# Build and push image
docker build -t registry.samson.media/sdlc-orchestrator:latest .
docker push registry.samson.media/sdlc-orchestrator:latest
# Create secrets
cp k8s/secret.yaml.example k8s/secret.yaml
# Edit k8s/secret.yaml with real values
kubectl apply -f k8s/secret.yaml
# Deploy
kubectl apply -f k8s/deployment.yaml
```
### Configure Gitea Webhooks
For each project, add these webhooks in **Settings → Webhooks**:
| Event | URL |
|-------|-----|
| Issues (opened) | `https://sdlc.samson.media/webhook/gitea-issue-triage` |
| Issues (labeled) | `https://sdlc.samson.media/webhook/gitea-issue-label` |
| Issue Comments (created) | `https://sdlc.samson.media/webhook/gitea-issue-comment` |
| Pull Request (opened, synchronized) | `https://sdlc.samson.media/webhook/gitea-pr-review` |
| Pull Request Review (submitted) | `https://sdlc.samson.media/webhook/gitea-pr-review-rework` |
| Issues (opened, labeled) — releases | `https://sdlc.samson.media/webhook/gitea-release` |
## Coder Template
The `coder/` directory contains the Terraform template for ephemeral Kubernetes workspaces. See `coder/README.md`.
## Migrating from n8n
1. Deploy this service to k3s
2. Update Gitea webhooks to point to `sdlc.samson.media` instead of `n8n.samson.media`
3. Verify with a test issue
4. Decommission n8n

42
coder/README.md Normal file
View File

@ -0,0 +1,42 @@
# Coder Template: Cloudflare Worker
A lightweight Coder workspace template for Cloudflare Workers development with Claude Code automation.
## Prerequisites
- Coder running at `coder.samson.media`
- `coder` CLI installed and authenticated
## Push Template to Coder
```bash
cd coder/cloudflare-worker
coder templates push cloudflare-worker
```
## Parameters
| Parameter | Description | Default |
|-----------|-------------|---------|
| `cpu` | CPU cores | 2 |
| `memory` | Memory (MB) | 4096 |
| `disk_size` | Home directory size (GB) | 10 |
| `issue_number` | Gitea issue number (for automation) | empty |
| `task_type` | Claude Code command to run (for automation) | empty |
| `repo_clone_url` | Git repo to clone | empty |
| `deploy_env` | Deploy environment (staging/production) | empty |
## Automated Usage (via n8n)
When `task_type` is set, the workspace startup script will:
1. Clone the repository from `repo_clone_url`
2. Install dependencies
3. Run Claude Code with the specified command
4. The workspace can be destroyed after completion
## Manual Usage
Create a workspace without `task_type` for interactive development:
```bash
coder create my-workspace --template cloudflare-worker
```

View File

@ -0,0 +1,691 @@
# =============================================================================
# Cloudflare Worker Development - Coder Template
# =============================================================================
# Lightweight workspace for Cloudflare Workers with Hono, Drizzle, and
# Claude Code automation. Supports automated SDLC via n8n triggers.
# =============================================================================
terraform {
required_providers {
coder = {
source = "coder/coder"
}
kubernetes = {
source = "hashicorp/kubernetes"
}
}
}
# =============================================================================
# Data Sources
# =============================================================================
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_provisioner" "me" {}
data "coder_external_auth" "github" {
id = "primary-github"
}
# =============================================================================
# Parameters
# =============================================================================
data "coder_parameter" "cpu" {
name = "cpu"
display_name = "CPU Cores"
description = "Number of CPU cores"
type = "number"
default = "2"
mutable = false
option {
name = "1 Core"
value = "1"
}
option {
name = "2 Cores"
value = "2"
}
option {
name = "4 Cores"
value = "4"
}
}
data "coder_parameter" "memory" {
name = "memory"
display_name = "Memory (MB)"
description = "Amount of memory"
type = "number"
default = "4096"
mutable = false
option {
name = "2048 MB"
value = "2048"
}
option {
name = "4096 MB"
value = "4096"
}
option {
name = "8192 MB"
value = "8192"
}
}
data "coder_parameter" "disk_size" {
name = "disk_size"
display_name = "Disk Size (GB)"
description = "Persistent home directory size"
type = "number"
default = "10"
mutable = false
option {
name = "5 GB"
value = "5"
}
option {
name = "10 GB"
value = "10"
}
option {
name = "20 GB"
value = "20"
}
}
# Automation Parameters
data "coder_parameter" "repo_clone_url" {
name = "repo_clone_url"
display_name = "Repository Clone URL"
description = "Git repo URL to clone (leave empty for manual setup)"
type = "string"
default = ""
mutable = false
}
data "coder_parameter" "task_type" {
name = "task_type"
display_name = "Task Type"
description = "Claude Code command to run automatically (leave empty for interactive use)"
type = "string"
default = ""
mutable = false
option {
name = "None (interactive)"
value = ""
}
option {
name = "Analyse"
value = "analyse"
}
option {
name = "Architect"
value = "architect"
}
option {
name = "Develop"
value = "develop"
}
option {
name = "Review Code"
value = "review-code"
}
option {
name = "Test"
value = "test"
}
option {
name = "Fix Bug"
value = "fix-bug"
}
option {
name = "Rework PR"
value = "rework-pr"
}
option {
name = "Release"
value = "release"
}
option {
name = "DevOps"
value = "devops"
}
option {
name = "Create Migration"
value = "create-migration"
}
}
data "coder_parameter" "issue_number" {
name = "issue_number"
display_name = "Issue/PR Number"
description = "Gitea issue or PR number (for automated tasks)"
type = "string"
default = ""
mutable = false
}
data "coder_parameter" "gitea_token" {
name = "gitea_token"
display_name = "Gitea API Token"
description = "API token for reading issues and posting comments"
type = "string"
default = ""
mutable = false
}
data "coder_parameter" "gitea_org" {
name = "gitea_org"
display_name = "Gitea Organization"
description = "Gitea organization name"
type = "string"
default = "claude"
mutable = false
}
data "coder_parameter" "gitea_repo" {
name = "gitea_repo"
display_name = "Gitea Repository"
description = "Gitea repository name"
type = "string"
default = ""
mutable = false
}
data "coder_parameter" "anthropic_api_key" {
name = "anthropic_api_key"
display_name = "Anthropic API Key"
description = "API key for Claude Code"
type = "string"
default = ""
mutable = false
}
data "coder_parameter" "deploy_env" {
name = "deploy_env"
display_name = "Deploy Environment"
description = "Target environment for deploy tasks"
type = "string"
default = ""
mutable = false
option {
name = "None"
value = ""
}
option {
name = "Staging"
value = "staging"
}
option {
name = "Production"
value = "production"
}
}
# =============================================================================
# Locals
# =============================================================================
locals {
namespace = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}"
labels = {
"app.kubernetes.io/name" = "coder-workspace"
"app.kubernetes.io/instance" = data.coder_workspace.me.name
"app.kubernetes.io/managed-by" = "coder"
"coder.workspace.owner" = data.coder_workspace_owner.me.name
}
}
# =============================================================================
# Namespace
# =============================================================================
resource "kubernetes_namespace_v1" "workspace" {
metadata {
name = local.namespace
labels = local.labels
annotations = {
"field.cattle.io/projectId" = "local:p-w2w48"
}
}
}
# =============================================================================
# Secrets - Registry Access
# =============================================================================
data "kubernetes_secret_v1" "registry_source" {
metadata {
name = "registry-samson-media"
namespace = "registry"
}
}
resource "kubernetes_secret_v1" "registry" {
metadata {
name = "registry-samson-media"
namespace = kubernetes_namespace_v1.workspace.metadata[0].name
labels = local.labels
}
type = "kubernetes.io/dockerconfigjson"
data = {
".dockerconfigjson" = data.kubernetes_secret_v1.registry_source.data[".dockerconfigjson"]
}
}
# =============================================================================
# RBAC
# =============================================================================
resource "kubernetes_service_account_v1" "workspace" {
metadata {
name = "workspace"
namespace = kubernetes_namespace_v1.workspace.metadata[0].name
labels = local.labels
}
}
resource "kubernetes_role_v1" "workspace" {
metadata {
name = "workspace-admin"
namespace = kubernetes_namespace_v1.workspace.metadata[0].name
labels = local.labels
}
rule {
api_groups = ["", "apps", "batch", "networking.k8s.io"]
resources = ["*"]
verbs = ["*"]
}
}
resource "kubernetes_role_binding_v1" "workspace" {
metadata {
name = "workspace-admin"
namespace = kubernetes_namespace_v1.workspace.metadata[0].name
labels = local.labels
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "Role"
name = kubernetes_role_v1.workspace.metadata[0].name
}
subject {
kind = "ServiceAccount"
name = kubernetes_service_account_v1.workspace.metadata[0].name
namespace = kubernetes_namespace_v1.workspace.metadata[0].name
}
}
resource "kubernetes_role_binding_v1" "rancher_user_access" {
metadata {
name = "rancher-user-access"
namespace = kubernetes_namespace_v1.workspace.metadata[0].name
labels = local.labels
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = "coder-developer"
}
subject {
api_group = "rbac.authorization.k8s.io"
kind = "User"
name = data.coder_workspace_owner.me.name
}
}
# =============================================================================
# Resource Quotas
# =============================================================================
resource "kubernetes_resource_quota_v1" "workspace" {
metadata {
name = "workspace-quota"
namespace = kubernetes_namespace_v1.workspace.metadata[0].name
labels = local.labels
}
spec {
hard = {
"requests.cpu" = "${tonumber(data.coder_parameter.cpu.value) * 500}m"
"limits.cpu" = "${tonumber(data.coder_parameter.cpu.value) * 1000}m"
"requests.memory" = "${tonumber(data.coder_parameter.memory.value) / 2}Mi"
"limits.memory" = "${data.coder_parameter.memory.value}Mi"
"requests.storage" = "${data.coder_parameter.disk_size.value}Gi"
"persistentvolumeclaims" = "1"
}
}
}
# =============================================================================
# Storage
# =============================================================================
resource "kubernetes_persistent_volume_claim_v1" "home" {
metadata {
name = "home"
namespace = kubernetes_namespace_v1.workspace.metadata[0].name
labels = local.labels
}
wait_until_bound = false
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = "longhorn-backup"
resources {
requests = {
storage = "${data.coder_parameter.disk_size.value}Gi"
}
}
}
}
# =============================================================================
# Coder Agent
# =============================================================================
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
dir = "/home/coder"
startup_script_behavior = "non-blocking"
# Configure auto-stop TTL in Coder template settings:
# Settings > Auto-stop > Default TTL: 1 hour
# This ensures automated workspaces don't run indefinitely.
metadata {
display_name = "Task Type"
key = "task_type"
script = "echo $TASK_TYPE"
interval = 0
}
metadata {
display_name = "Issue Number"
key = "issue_number"
script = "echo $ISSUE_NUMBER"
interval = 0
}
display_apps {
vscode = true
vscode_insiders = false
ssh_helper = true
port_forwarding_helper = true
web_terminal = true
}
env = {
ANTHROPIC_API_KEY = data.coder_parameter.anthropic_api_key.value
GITHUB_TOKEN = data.coder_external_auth.github.access_token
GITEA_TOKEN = data.coder_parameter.gitea_token.value
GITEA_ORG = data.coder_parameter.gitea_org.value
GITEA_REPO = data.coder_parameter.gitea_repo.value
TASK_TYPE = data.coder_parameter.task_type.value
ISSUE_NUMBER = data.coder_parameter.issue_number.value
REPO_CLONE_URL = data.coder_parameter.repo_clone_url.value
DEPLOY_ENV = data.coder_parameter.deploy_env.value
}
startup_script = <<-EOT
#!/bin/bash
set -e
# --- Install Node.js 22 ---
if ! command -v node &> /dev/null; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
fi
# --- Install global tools ---
if ! command -v wrangler &> /dev/null; then
sudo npm install -g wrangler @anthropic-ai/claude-code
fi
# --- Install Playwright system dependencies ---
if ! dpkg -s libgbm1 &> /dev/null; then
sudo apt-get update
sudo apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 \
libcups2t64 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64 libatspi2.0-0 \
|| sudo apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 libatspi2.0-0
fi
# --- Configure git ---
git config --global user.name "${data.coder_workspace_owner.me.full_name}"
git config --global user.email "${data.coder_workspace_owner.me.email}"
# Configure git to use GITEA_TOKEN for HTTPS push/pull
if [ -n "$GITEA_TOKEN" ]; then
git config --global credential.helper 'store'
echo "https://oauth2:$GITEA_TOKEN@gitea.samson.media" > ~/.git-credentials
chmod 600 ~/.git-credentials
fi
# --- Clone repository ---
if [ -n "$REPO_CLONE_URL" ] && [ ! -d ~/project ]; then
git clone "$REPO_CLONE_URL" ~/project
cd ~/project
# Switch to develop branch if it exists
git fetch origin develop 2>/dev/null && git checkout develop 2>/dev/null || true
# Install backend deps
npm ci --legacy-peer-deps
# Install frontend deps
cd frontend && npm ci && cd ..
# Build frontend (required vitest fails without frontend/dist)
npm run build:frontend
# Install Playwright Chromium
npx playwright install chromium
# Apply local migrations
npm run db:migrate:local
fi
# --- Automated task execution ---
if [ -n "$TASK_TYPE" ] && [ -d ~/project ]; then
cd ~/project
ARGS="$ISSUE_NUMBER"
if [ "$TASK_TYPE" = "devops" ] && [ -n "$DEPLOY_ENV" ]; then
ARGS="$DEPLOY_ENV"
fi
# Run Claude Code in non-interactive mode
claude --print --dangerously-skip-permissions "/project:$TASK_TYPE $ARGS" 2>&1 | tee ~/task-output.log
echo "Task completed. Output saved to ~/task-output.log"
fi
EOT
}
# =============================================================================
# Main Workspace Deployment
# =============================================================================
resource "kubernetes_deployment_v1" "workspace" {
metadata {
name = "workspace"
namespace = kubernetes_namespace_v1.workspace.metadata[0].name
labels = local.labels
}
wait_for_rollout = false
spec {
replicas = data.coder_workspace.me.start_count
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "workspace"
}
}
template {
metadata {
labels = merge(local.labels, {
app = "workspace"
})
}
spec {
service_account_name = kubernetes_service_account_v1.workspace.metadata[0].name
security_context {
run_as_user = 1000
run_as_group = 1000
fs_group = 1000
}
image_pull_secrets {
name = kubernetes_secret_v1.registry.metadata[0].name
}
init_container {
name = "fix-permissions"
image = "busybox:latest"
command = ["sh", "-c", <<-EOC
if [ ! -d /home/coder/project ]; then
mkdir -p /home/coder/project
fi
chown -R 1000:1000 /home/coder
EOC
]
volume_mount {
name = "home"
mount_path = "/home/coder"
}
resources {
requests = {
cpu = "5m"
memory = "8Mi"
}
limits = {
cpu = "50m"
memory = "32Mi"
}
}
security_context {
run_as_user = 0
}
}
container {
name = "coder-agent"
image = "codercom/enterprise-base:ubuntu-arm64"
command = ["sh", "-c", coder_agent.main.init_script]
env {
name = "CODER_AGENT_TOKEN"
value = coder_agent.main.token
}
env {
name = "ANTHROPIC_API_KEY"
value = data.coder_parameter.anthropic_api_key.value
}
env {
name = "GITHUB_TOKEN"
value = data.coder_external_auth.github.access_token
}
env {
name = "GITEA_TOKEN"
value = data.coder_parameter.gitea_token.value
}
env {
name = "GITEA_ORG"
value = data.coder_parameter.gitea_org.value
}
env {
name = "GITEA_REPO"
value = data.coder_parameter.gitea_repo.value
}
env {
name = "TASK_TYPE"
value = data.coder_parameter.task_type.value
}
env {
name = "ISSUE_NUMBER"
value = data.coder_parameter.issue_number.value
}
env {
name = "REPO_CLONE_URL"
value = data.coder_parameter.repo_clone_url.value
}
env {
name = "DEPLOY_ENV"
value = data.coder_parameter.deploy_env.value
}
resources {
requests = {
cpu = "${tonumber(data.coder_parameter.cpu.value) * 500}m"
memory = "${tonumber(data.coder_parameter.memory.value) / 2}Mi"
}
limits = {
cpu = "${tonumber(data.coder_parameter.cpu.value) * 1000}m"
memory = "${data.coder_parameter.memory.value}Mi"
}
}
volume_mount {
name = "home"
mount_path = "/home/coder"
}
security_context {
run_as_user = 1000
run_as_group = 1000
}
}
volume {
name = "home"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim_v1.home.metadata[0].name
}
}
}
}
}
}

88
k8s/deployment.yaml Normal file
View File

@ -0,0 +1,88 @@
apiVersion: v1
kind: Namespace
metadata:
name: sdlc-orchestrator
---
apiVersion: v1
kind: Service
metadata:
name: sdlc-orchestrator
namespace: sdlc-orchestrator
spec:
selector:
app: sdlc-orchestrator
ports:
- port: 3000
targetPort: 3000
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sdlc-orchestrator
namespace: sdlc-orchestrator
spec:
replicas: 1
selector:
matchLabels:
app: sdlc-orchestrator
template:
metadata:
labels:
app: sdlc-orchestrator
spec:
containers:
- name: orchestrator
image: registry.samson.media/sdlc-orchestrator:latest
ports:
- containerPort: 3000
envFrom:
- secretRef:
name: orchestrator-secrets
env:
- name: PORT
value: "3000"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 3
periodSeconds: 10
imagePullSecrets:
- name: registry-samson-media
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sdlc-orchestrator
namespace: sdlc-orchestrator
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
spec:
tls:
- hosts:
- sdlc.samson.media
secretName: sdlc-orchestrator-tls
rules:
- host: sdlc.samson.media
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sdlc-orchestrator
port:
number: 3000

22
k8s/secret.yaml.example Normal file
View File

@ -0,0 +1,22 @@
# Copy to secret.yaml and fill in real values, then:
# kubectl apply -f k8s/secret.yaml
#
# NEVER commit the real secret.yaml to git.
apiVersion: v1
kind: Secret
metadata:
name: orchestrator-secrets
namespace: sdlc-orchestrator
type: Opaque
stringData:
CODER_URL: "https://coder.samson.media"
CODER_TOKEN: "<coder-session-token>"
CODER_TEMPLATE_ID: "<coder-template-id>"
GITEA_DEV_TOKEN: "<gitea-claude-dev-token>"
GITEA_REVIEW_TOKEN: "<gitea-claude-review-token>"
ANTHROPIC_API_KEY: "<anthropic-api-key>"
GITEA_URL: "https://gitea.samson.media"
BOT_DEV_USERNAME: "claude-dev"
BOT_REVIEW_USERNAME: "claude-review"
# Comma-separated: org/repo=clone_url
MAINTENANCE_REPOS: "claude/babble=https://gitea.samson.media/claude/babble.git"

1722
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "sdlc-orchestrator",
"version": "1.0.0",
"description": "Webhook-driven SDLC orchestrator for Gitea + Coder + Claude Code",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/",
"format": "prettier --write src/",
"format:check": "prettier --check src/"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
"hono": "^4.7.6",
"node-cron": "^3.0.3"
},
"devDependencies": {
"@types/node": "^22.15.3",
"@types/node-cron": "^3.0.11",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"eslint": "^9.25.1",
"prettier": "^3.5.3"
}
}

48
src/config.ts Normal file
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,
};
}

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 });
};
}

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 });
};
}

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 });
};
}

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
src/handlers/pr-review.ts Normal file
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 testreviewtest 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
src/handlers/pr-rework.ts Normal file
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
src/handlers/release.ts Normal file
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
src/index.ts Normal file
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
src/services/coder.ts Normal file
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
src/services/gitea.ts Normal file
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
src/types.ts Normal file
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;
}

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}