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:
commit
55dee1f2bd
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.dev.vars
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue