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:
@@ -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 });
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user