Scaffold Matrix + GitOps + Claude automation stack
build-agent / build-and-push (push) Failing after 18s
build-agent / build-and-push (push) Failing after 18s
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user