Scaffold Matrix + GitOps + Claude automation stack
build-agent / build-and-push (push) Failing after 18s

This commit is contained in:
2026-08-23 10:25:57 +00:00
commit 357791d6e3
12 changed files with 576 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
.env
+23
View File
@@ -0,0 +1,23 @@
FROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
openssh-client \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY src ./src
RUN git config --global user.email "claude-agent@apps.williamturner.eu" \
&& git config --global user.name "claude-agent" \
&& git config --global --add safe.directory '*'
ENV NODE_ENV=production
EXPOSE 3001
CMD ["node", "src/server.js"]
+14
View File
@@ -0,0 +1,14 @@
{
"name": "claude-gitops-agent",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"express": "^4.19.2",
"matrix-bot-sdk": "^0.7.1"
}
}
+54
View File
@@ -0,0 +1,54 @@
const GITEA_URL = process.env.GITEA_URL;
const GITEA_TOKEN = process.env.GITEA_TOKEN;
function authHeaders() {
return {
Authorization: `token ${GITEA_TOKEN}`,
"Content-Type": "application/json",
};
}
async function assertOk(res, action) {
if (!res.ok) {
throw new Error(`${action} failed: ${res.status} ${await res.text()}`);
}
}
export async function postPRComment(owner, repo, index, body) {
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/issues/${index}/comments`;
const res = await fetch(url, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ body }),
});
await assertOk(res, "post PR comment");
}
export async function createBranch(owner, repo, newBranch, oldBranch = "main") {
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/branches`;
const res = await fetch(url, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ new_branch_name: newBranch, old_branch_name: oldBranch }),
});
await assertOk(res, "create branch");
}
export async function createPullRequest(owner, repo, { head, base = "main", title, body }) {
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/pulls`;
const res = await fetch(url, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ head, base, title, body }),
});
await assertOk(res, "create PR");
return res.json();
}
// Injects the agent's token into a Gitea clone URL so git operations don't need SSH keys.
export function authenticatedCloneUrl(cloneUrl) {
const u = new URL(cloneUrl);
u.username = "claude-agent";
u.password = GITEA_TOKEN;
return u.toString();
}
+55
View File
@@ -0,0 +1,55 @@
import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from "matrix-bot-sdk";
import { runChatTask } from "./runner.js";
const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL;
const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN;
const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID;
const GITEA_URL = process.env.GITEA_URL;
// "!claude owner/repo do the thing" — everything after the repo slug is the instruction.
function parseCommand(text) {
const match = text.match(/^!claude\s+([^\s/]+\/[^\s/]+)\s+(.+)$/s);
if (!match) return null;
const [, repoFullName, instruction] = match;
return { repoFullName, instruction: instruction.trim() };
}
export async function startMatrixBot() {
if (!HOMESERVER_URL || !ACCESS_TOKEN || !CONTROL_ROOM_ID) {
console.warn("Matrix env vars not set — skipping bot startup");
return;
}
const storage = new SimpleFsStorageProvider("/workspace/matrix-bot-storage.json");
const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage);
AutojoinRoomsMixin.setupOnClient(client);
const selfUserId = await client.getUserId();
client.on("room.message", async (roomId, event) => {
// Invite-only control room enforces who can reach the bot at all; this just
// scopes command handling to that one room and ignores the bot's own messages.
if (roomId !== CONTROL_ROOM_ID) return;
if (event.sender === selfUserId) return;
const body = event.content?.body;
if (!body) return;
const cmd = parseCommand(body);
if (!cmd) return;
const [owner, repo] = cmd.repoFullName.split("/");
await client.sendText(roomId, `Working on it: ${cmd.repoFullName}${cmd.instruction}`);
try {
const cloneUrl = `${GITEA_URL}/${owner}/${repo}.git`;
const pr = await runChatTask({ owner, repo, cloneUrl, instruction: cmd.instruction });
await client.sendText(roomId, `Opened PR: ${pr.html_url}`);
} catch (err) {
console.error("chat task failed", err);
await client.sendText(roomId, `Failed: ${err.message}`);
}
});
await client.start();
console.log("Matrix bot started, room:", CONTROL_ROOM_ID);
}
+99
View File
@@ -0,0 +1,99 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, rm, mkdir } from "node:fs/promises";
import path from "node:path";
import crypto from "node:crypto";
import { authenticatedCloneUrl, createBranch, createPullRequest } from "./gitea.js";
const execFileAsync = promisify(execFile);
const WORKSPACE_ROOT = "/workspace";
async function run(cmd, args, opts = {}) {
const { stdout } = await execFileAsync(cmd, args, {
maxBuffer: 1024 * 1024 * 32,
...opts,
});
return stdout;
}
async function withWorkspace(fn) {
await mkdir(WORKSPACE_ROOT, { recursive: true });
const dir = await mkdtemp(path.join(WORKSPACE_ROOT, "job-"));
try {
return await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
// Runs Claude Code headless. Unattended containers have no TTY to answer permission
// prompts, so this trusts the sandboxing of the throwaway clone dir instead:
// bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so
// Claude can never push/commit/checkout itself — this script owns those steps.
async function runClaude(cwd, prompt, { allowEdits }) {
const disallowed = ["Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)"];
if (!allowEdits) disallowed.push("Edit", "Write", "NotebookEdit");
const args = [
"-p", prompt,
"--output-format", "text",
"--permission-mode", "bypassPermissions",
"--disallowedTools", disallowed.join(","),
];
return run("claude", args, { cwd });
}
export async function reviewPullRequest({ owner, repo, ref, cloneUrl, prTitle, prBody }) {
return withWorkspace(async (dir) => {
const authedUrl = authenticatedCloneUrl(cloneUrl);
await run("git", ["clone", "--quiet", authedUrl, dir]);
await run("git", ["fetch", "--quiet", "origin", ref], { cwd: dir });
await run("git", ["checkout", "--quiet", ref], { cwd: dir });
const prompt = [
"You are reviewing a pull request. Diff HEAD against the base branch (origin/main)",
"and give a concise, specific code review: correctness bugs first, then",
"simplification/reuse/efficiency notes. Read-only — do not edit any files.",
`PR title: ${prTitle}`,
`PR description:\n${prBody}`,
].join("\n");
return runClaude(dir, prompt, { allowEdits: false });
});
}
export async function runChatTask({ owner, repo, cloneUrl, instruction }) {
return withWorkspace(async (dir) => {
const authedUrl = authenticatedCloneUrl(cloneUrl);
await run("git", ["clone", "--quiet", authedUrl, dir]);
const branch = `claude/${crypto.randomBytes(4).toString("hex")}`;
await createBranch(owner, repo, branch);
await run("git", ["fetch", "--quiet", "origin", branch], { cwd: dir });
await run("git", ["checkout", "--quiet", branch], { cwd: dir });
const prompt = [
"Implement the change described below in this repository. Make the smallest",
"correct change that satisfies it. Do not run git commit, git push, or git",
"checkout yourself — just edit files; committing and pushing happens separately.",
`Instruction: ${instruction}`,
].join("\n");
await runClaude(dir, prompt, { allowEdits: true });
await run("git", ["add", "-A"], { cwd: dir });
const status = await run("git", ["status", "--porcelain"], { cwd: dir });
if (!status.trim()) {
throw new Error("Claude made no changes for this instruction");
}
await run("git", ["commit", "-m", `claude: ${instruction}`.slice(0, 200)], { cwd: dir });
await run("git", ["push", "--quiet", "origin", branch], { cwd: dir });
return createPullRequest(owner, repo, {
head: branch,
title: `claude: ${instruction}`.slice(0, 200),
body: `Requested via Matrix:\n\n> ${instruction}`,
});
});
}
+66
View File
@@ -0,0 +1,66 @@
import express from "express";
import crypto from "node:crypto";
import { postPRComment } from "./gitea.js";
import { reviewPullRequest } from "./runner.js";
import { startMatrixBot } from "./matrixBot.js";
const app = express();
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);
const PORT = process.env.PORT || 3001;
const WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET;
function verifySignature(req) {
if (!WEBHOOK_SECRET) return false;
const sig = req.get("X-Gitea-Signature");
if (!sig) return false;
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.rawBody).digest("hex");
const sigBuf = Buffer.from(sig, "hex");
const expBuf = Buffer.from(expected, "hex");
return sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
}
app.get("/healthz", (_req, res) => res.send("ok"));
app.post("/webhooks/gitea", async (req, res) => {
if (!verifySignature(req)) {
return res.status(401).send("bad signature");
}
// Ack immediately — Gitea has a short webhook timeout and the review itself takes a while.
res.status(202).send("accepted");
const event = req.get("X-Gitea-Event");
const body = req.body;
try {
if (event === "pull_request" && ["opened", "synchronize"].includes(body.action)) {
const { repository, pull_request: pr } = body;
const [owner, repo] = repository.full_name.split("/");
const review = await reviewPullRequest({
owner,
repo,
ref: pr.head.sha,
cloneUrl: repository.clone_url,
prTitle: pr.title,
prBody: pr.body || "",
});
await postPRComment(owner, repo, pr.number, review);
}
} catch (err) {
console.error("webhook handling failed:", err);
}
});
app.listen(PORT, () => {
console.log(`claude-agent listening on :${PORT}`);
});
startMatrixBot().catch((err) => {
console.error("matrix bot failed to start:", err);
});