This commit is contained in:
Daan Schouteden
2026-06-02 11:39:41 +02:00
commit 3d0e28f427
27 changed files with 3426 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
const envSchema = z.object({
CURSOR_API_KEY: z.string().min(1),
GITEA_TOKEN: z.string().min(1),
GITEA_BASE_URL: z.url(),
GITEA_BOT_LOGIN: z.string().min(1),
WEBHOOK_SECRET: z.string().min(1),
PORT: z.coerce.number().int().positive().default(8787),
DEFAULT_BASE_BRANCH: z.string().default("main"),
MAX_INLINE_COMMENTS: z.coerce.number().int().positive().default(5),
REVIEW_TIMEOUT_MS: z.coerce.number().int().positive().default(120000),
DEDUPE_TTL_SECONDS: z.coerce.number().int().positive().default(1800)
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(raw: NodeJS.ProcessEnv = process.env): Env {
return envSchema.parse(raw);
}
+51
View File
@@ -0,0 +1,51 @@
import { parse } from "yaml";
import { GiteaClient } from "../gitea/client.js";
export type RepoConfig = {
enabled?: boolean;
base_branch?: string;
model?: string;
max_inline_comments?: number;
labels_skip?: string[];
read_optional_paths?: string[];
rules_source_mode?: "repo_first_with_central_fallback";
do_not_copy_repo_rules_into_bot_repo?: boolean;
};
const DEFAULT_OPTIONAL_RULE_PATHS = [
"AGENTS.md",
"docs/pr-review.md",
".cursor/skills/pr-review/SKILL.md",
".cursor/skills/pr-review/gitea.md"
];
export async function loadRepoConfig(input: {
gitea: GiteaClient;
owner: string;
repo: string;
ref: string;
}): Promise<{ config: RepoConfig; ruleFiles: Array<{ path: string; content: string }> }> {
const configPath = ".gitea/pr-review-bot.yml";
const [configContent, rules] = await Promise.all([
input.gitea.getFileIfExists(input.owner, input.repo, configPath, input.ref),
Promise.all(
DEFAULT_OPTIONAL_RULE_PATHS.map(async (path) => {
const content = await input.gitea.getFileIfExists(input.owner, input.repo, path, input.ref);
return content ? { path, content } : null;
})
)
]);
let parsedConfig: RepoConfig = {};
if (configContent) {
const loaded = parse(configContent) as RepoConfig | null;
parsedConfig = loaded ?? {};
}
const optionalPaths = parsedConfig.read_optional_paths ?? DEFAULT_OPTIONAL_RULE_PATHS;
const ruleFiles = rules.filter((entry): entry is { path: string; content: string } => {
return entry !== null && optionalPaths.includes(entry.path);
});
return { config: parsedConfig, ruleFiles };
}
+75
View File
@@ -0,0 +1,75 @@
import { parseReviewResult, ReviewResult } from "./review-schema.js";
type RunReviewInput = {
apiKey: string;
prompt: string;
timeoutMs: number;
model?: string;
};
export async function runCursorReview(input: RunReviewInput): Promise<ReviewResult> {
const sdk = (await import("@cursor/sdk")) as any;
const Agent = sdk.Agent;
if (!Agent) {
throw new Error("Cursor SDK Agent API is unavailable");
}
const agent = new Agent({
apiKey: input.apiKey,
runtime: "cloud",
model: input.model
});
const result = await withTimeout(
agent.prompt({
prompt: input.prompt
}),
input.timeoutMs,
"Cursor review timed out"
);
const text = extractText(result);
const parsed = JSON.parse(text) as unknown;
return parseReviewResult(parsed);
}
function extractText(result: unknown): string {
if (typeof result === "string") {
return result;
}
if (result && typeof result === "object") {
const maybe = result as Record<string, unknown>;
if (typeof maybe.output_text === "string") {
return maybe.output_text;
}
if (typeof maybe.text === "string") {
return maybe.text;
}
if (Array.isArray(maybe.messages)) {
const last = maybe.messages.at(-1) as any;
const content = last?.content;
if (typeof content === "string") {
return content;
}
}
}
throw new Error("Could not extract Cursor response text");
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
})
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
export const reviewSchema = z.object({
verdict: z.enum(["approve", "request_changes", "comment"]),
event: z.enum(["APPROVE", "REQUEST_CHANGES", "COMMENT"]),
body: z.string().min(1),
comments: z.array(
z.object({
path: z.string().min(1),
new_position: z.number().int().min(1),
body: z.string().min(1)
})
)
});
export type ReviewResult = z.infer<typeof reviewSchema>;
export function parseReviewResult(payload: unknown): ReviewResult {
return reviewSchema.parse(payload);
}
+35
View File
@@ -0,0 +1,35 @@
type DedupeRecord = { expiresAt: number };
export class DedupeStore {
private readonly data = new Map<string, DedupeRecord>();
constructor(private readonly ttlSeconds: number) {}
createKey(input: {
owner: string;
repo: string;
prNumber: number;
headSha: string;
}): string {
return `${input.owner}/${input.repo}#${input.prNumber}#${input.headSha}`;
}
has(key: string): boolean {
this.prune();
return this.data.has(key);
}
mark(key: string): void {
const expiresAt = Date.now() + this.ttlSeconds * 1000;
this.data.set(key, { expiresAt });
}
private prune(): void {
const now = Date.now();
for (const [k, record] of this.data.entries()) {
if (record.expiresAt <= now) {
this.data.delete(k);
}
}
}
}
+36
View File
@@ -0,0 +1,36 @@
import { RoutedEvent } from "../types/events.js";
import { RepoConfig } from "../config/load-repo-config.js";
export function shouldProcessEvent(input: {
event: RoutedEvent;
repoConfig: RepoConfig;
defaultBaseBranch: string;
botLogin: string;
}): { process: boolean; reason?: string } {
const pr = input.event.payload.pull_request;
const sender = input.event.payload.sender?.login;
if (sender && sender === input.botLogin) {
return { process: false, reason: "loop_guard_self_sender" };
}
if (input.repoConfig.enabled === false) {
return { process: false, reason: "repo_disabled" };
}
if (input.repoConfig.labels_skip && input.repoConfig.labels_skip.length > 0) {
const labels = new Set((pr.labels ?? []).map((l) => l.name));
for (const skip of input.repoConfig.labels_skip) {
if (labels.has(skip)) {
return { process: false, reason: "skip_label" };
}
}
}
const expectedBase = input.repoConfig.base_branch ?? input.defaultBaseBranch;
if (expectedBase && pr.base.ref !== expectedBase) {
return { process: false, reason: "base_branch_mismatch" };
}
return { process: true };
}
+114
View File
@@ -0,0 +1,114 @@
type RequestOptions = {
method?: string;
body?: unknown;
timeoutMs?: number;
};
export type PullFile = {
filename: string;
patch?: string;
status?: string;
};
export type PullDetails = {
number: number;
title: string;
body: string | null;
head: { sha: string; ref: string };
base: { ref: string };
labels?: Array<{ name: string }>;
requested_reviewers?: Array<{ login: string }>;
};
export class GiteaClient {
constructor(
private readonly baseUrl: string,
private readonly token: string,
private readonly requestTimeoutMs: number
) {}
async getPull(owner: string, repo: string, index: number): Promise<PullDetails> {
return this.requestJson(`/repos/${owner}/${repo}/pulls/${index}`);
}
async getPullFiles(owner: string, repo: string, index: number): Promise<PullFile[]> {
return this.requestJson(`/repos/${owner}/${repo}/pulls/${index}/files`);
}
async getReviews(owner: string, repo: string, index: number): Promise<Array<{ id: number; user?: { login?: string } }>> {
return this.requestJson(`/repos/${owner}/${repo}/pulls/${index}/reviews`);
}
async createReview(owner: string, repo: string, index: number, body: unknown): Promise<unknown> {
return this.requestJson(`/repos/${owner}/${repo}/pulls/${index}/reviews`, {
method: "POST",
body
});
}
async deleteReview(owner: string, repo: string, index: number, reviewId: number): Promise<void> {
await this.requestJson(`/repos/${owner}/${repo}/pulls/${index}/reviews/${reviewId}`, {
method: "DELETE"
});
}
async patchPull(owner: string, repo: string, index: number, body: unknown): Promise<unknown> {
return this.requestJson(`/repos/${owner}/${repo}/pulls/${index}`, {
method: "PATCH",
body
});
}
async getFileIfExists(owner: string, repo: string, path: string, ref: string): Promise<string | null> {
const encodedPath = path
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
const query = new URLSearchParams({ ref }).toString();
const result = await this.request(`/repos/${owner}/${repo}/contents/${encodedPath}?${query}`, {
method: "GET"
});
if (result.status === 404) {
return null;
}
if (!result.ok) {
throw new Error(`Failed to load ${path}: ${result.status}`);
}
const json = (await result.json()) as { content?: string; encoding?: string };
if (!json.content || json.encoding !== "base64") {
return null;
}
return Buffer.from(json.content, "base64").toString("utf8");
}
private async requestJson(path: string, options: RequestOptions = {}): Promise<any> {
const response = await this.request(path, options);
if (!response.ok) {
const text = await response.text();
throw new Error(`Gitea request failed (${response.status}): ${text}`);
}
if (response.status === 204) {
return undefined;
}
return response.json();
}
private async request(path: string, options: RequestOptions): Promise<Response> {
const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(`${this.baseUrl}/api/v1${path}`, {
method: options.method ?? "GET",
headers: {
Authorization: `token ${this.token}`,
"Content-Type": "application/json"
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: controller.signal
});
} finally {
clearTimeout(timer);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import { GiteaClient, PullFile } from "./client.js";
import { ReviewResult } from "../cursor/review-schema.js";
export async function deletePriorBotReviews(input: {
gitea: GiteaClient;
owner: string;
repo: string;
prNumber: number;
botLogin: string;
}): Promise<void> {
const reviews = await input.gitea.getReviews(input.owner, input.repo, input.prNumber);
const botReviews = reviews.filter((review) => review.user?.login === input.botLogin);
await Promise.all(
botReviews.map((review) =>
input.gitea.deleteReview(input.owner, input.repo, input.prNumber, review.id)
)
);
}
export async function postReview(input: {
gitea: GiteaClient;
owner: string;
repo: string;
prNumber: number;
files: PullFile[];
review: ReviewResult;
maxInlineComments: number;
}): Promise<void> {
const validPaths = new Set(input.files.map((f) => f.filename));
const allInlineValid = input.review.comments.every(
(c) => validPaths.has(c.path) && c.new_position > 0
);
const comments = allInlineValid
? input.review.comments.slice(0, input.maxInlineComments).map((c) => ({
path: c.path,
body: c.body,
new_position: c.new_position
}))
: [];
const hasInline = comments.length > 0;
await input.gitea.createReview(input.owner, input.repo, input.prNumber, {
event: input.review.event,
body: input.review.body,
comments: hasInline ? comments : []
});
}
+19
View File
@@ -0,0 +1,19 @@
import { GiteaClient } from "./client.js";
export async function removeBotFromReviewers(input: {
gitea: GiteaClient;
owner: string;
repo: string;
prNumber: number;
botLogin: string;
}): Promise<void> {
const pr = await input.gitea.getPull(input.owner, input.repo, input.prNumber);
const currentReviewers = pr.requested_reviewers ?? [];
const reviewers = currentReviewers
.map((r) => r.login)
.filter((login): login is string => Boolean(login) && login !== input.botLogin);
await input.gitea.patchPull(input.owner, input.repo, input.prNumber, {
reviewers
});
}
+41
View File
@@ -0,0 +1,41 @@
import { PullDetails, PullFile } from "../gitea/client.js";
export function buildReviewPrompt(input: {
owner: string;
repo: string;
pull: PullDetails;
files: PullFile[];
maxInlineComments: number;
repoRuleFiles: Array<{ path: string; content: string }>;
}): string {
const filesBlock = input.files
.map((file) => {
const patch = file.patch ?? "";
return `FILE: ${file.filename}\nPATCH:\n${patch}\n---`;
})
.join("\n");
const rulesBlock = input.repoRuleFiles
.map((entry) => `RULE FILE: ${entry.path}\n${entry.content}\n---`)
.join("\n");
return [
"You are a senior code reviewer for this pull request.",
"Return strict JSON only with fields: verdict,event,body,comments.",
"Use event one of APPROVE, REQUEST_CHANGES, COMMENT.",
`At most ${input.maxInlineComments} inline comments.`,
"Inline comments MUST have a changed file path and new_position >= 1.",
"",
`Repository: ${input.owner}/${input.repo}`,
`PR #${input.pull.number}: ${input.pull.title}`,
`Base branch: ${input.pull.base.ref}`,
`Head ref: ${input.pull.head.ref}`,
"",
"PR Body:",
input.pull.body ?? "(empty)",
"",
rulesBlock ? `Repository Review Rules:\n${rulesBlock}` : "Repository Review Rules: (none found)",
"",
`Changed files and patches:\n${filesBlock}`
].join("\n");
}
+26
View File
@@ -0,0 +1,26 @@
export async function retry<T>(input: {
fn: () => Promise<T>;
retries: number;
initialDelayMs: number;
shouldRetry?: (error: unknown) => boolean;
}): Promise<T> {
let attempt = 0;
let delay = input.initialDelayMs;
while (true) {
try {
return await input.fn();
} catch (error) {
attempt += 1;
const retryable = input.shouldRetry ? input.shouldRetry(error) : true;
if (!retryable || attempt > input.retries) {
throw error;
}
await sleep(delay);
delay *= 2;
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+141
View File
@@ -0,0 +1,141 @@
import { Env } from "../config/env.js";
import { loadRepoConfig } from "../config/load-repo-config.js";
import { runCursorReview } from "../cursor/review-agent.js";
import { DedupeStore } from "../domain/dedupe-store.js";
import { shouldProcessEvent } from "../domain/should-process-event.js";
import { GiteaClient } from "../gitea/client.js";
import { deletePriorBotReviews, postReview } from "../gitea/review-api.js";
import { removeBotFromReviewers } from "../gitea/reviewer-api.js";
import { buildReviewPrompt } from "../prompt/build-review-prompt.js";
import { RoutedEvent } from "../types/events.js";
import { retry } from "./retry.js";
export async function runReview(input: {
env: Env;
event: RoutedEvent;
dedupe: DedupeStore;
correlationId: string;
}): Promise<"skipped" | "success"> {
const owner = input.event.payload.repository.owner.login;
const repo = input.event.payload.repository.name;
const prNumber = input.event.payload.pull_request.number;
const headSha = input.event.payload.pull_request.head.sha;
const dedupeKey = input.dedupe.createKey({
owner,
repo,
prNumber,
headSha
});
if (input.dedupe.has(dedupeKey)) {
return "skipped";
}
input.dedupe.mark(dedupeKey);
const gitea = new GiteaClient(input.env.GITEA_BASE_URL, input.env.GITEA_TOKEN, 30000);
const pull = await retry({
fn: () => gitea.getPull(owner, repo, prNumber),
retries: 2,
initialDelayMs: 300
});
const { config: repoConfig, ruleFiles } = await retry({
fn: () =>
loadRepoConfig({
gitea,
owner,
repo,
ref: pull.head.ref
}),
retries: 2,
initialDelayMs: 300
});
const should = shouldProcessEvent({
event: input.event,
repoConfig,
defaultBaseBranch: input.env.DEFAULT_BASE_BRANCH,
botLogin: input.env.GITEA_BOT_LOGIN
});
if (!should.process) {
return "skipped";
}
const files = await retry({
fn: () => gitea.getPullFiles(owner, repo, prNumber),
retries: 2,
initialDelayMs: 300
});
const maxInlineComments = repoConfig.max_inline_comments ?? input.env.MAX_INLINE_COMMENTS;
const prompt = buildReviewPrompt({
owner,
repo,
pull,
files,
maxInlineComments,
repoRuleFiles: ruleFiles
});
const review = await retry({
fn: () =>
runCursorReview({
apiKey: input.env.CURSOR_API_KEY,
prompt,
timeoutMs: input.env.REVIEW_TIMEOUT_MS,
model: repoConfig.model
}),
retries: 2,
initialDelayMs: 500
});
await retry({
fn: () =>
deletePriorBotReviews({
gitea,
owner,
repo,
prNumber,
botLogin: input.env.GITEA_BOT_LOGIN
}),
retries: 2,
initialDelayMs: 300
});
await retry({
fn: () =>
postReview({
gitea,
owner,
repo,
prNumber,
files,
review,
maxInlineComments
}),
retries: 2,
initialDelayMs: 300
});
await retry({
fn: () =>
removeBotFromReviewers({
gitea,
owner,
repo,
prNumber,
botLogin: input.env.GITEA_BOT_LOGIN
}),
retries: 2,
initialDelayMs: 300
});
console.log(
JSON.stringify({
correlation_id: input.correlationId,
owner,
repo,
pr_number: prNumber,
head_sha: headSha,
outcome: "success"
})
);
return "success";
}
+84
View File
@@ -0,0 +1,84 @@
import crypto from "node:crypto";
import http from "node:http";
import { loadEnv } from "./config/env.js";
import { DedupeStore } from "./domain/dedupe-store.js";
import { runReview } from "./run/review-runner.js";
import { PullRequestWebhookPayload } from "./types/events.js";
import { routeEvent } from "./webhook/event-router.js";
import { verifySignature } from "./webhook/verify-signature.js";
const env = loadEnv();
const dedupe = new DedupeStore(env.DEDUPE_TTL_SECONDS);
const server = http.createServer(async (req, res) => {
if (req.method === "GET" && req.url === "/healthz") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method !== "POST" || req.url !== "/webhooks/gitea") {
res.writeHead(404);
res.end("not found");
return;
}
const body = await readBody(req);
const signature = req.headers["x-gitea-signature"];
const signatureValue = Array.isArray(signature) ? signature[0] : signature;
if (!verifySignature({ secret: env.WEBHOOK_SECRET, body, signatureHeader: signatureValue })) {
res.writeHead(401);
res.end("invalid signature");
return;
}
const eventNameHeader = req.headers["x-gitea-event"];
const eventName = Array.isArray(eventNameHeader) ? eventNameHeader[0] : eventNameHeader;
const payload = JSON.parse(body) as PullRequestWebhookPayload;
const routed = routeEvent({
eventName,
payload,
botLogin: env.GITEA_BOT_LOGIN
});
if (!routed) {
res.writeHead(202);
res.end("ignored");
return;
}
const correlationId = crypto.randomUUID();
try {
const outcome = await runReview({
env,
event: routed,
dedupe,
correlationId
});
res.writeHead(200);
res.end(outcome);
} catch (error) {
console.error(
JSON.stringify({
correlation_id: correlationId,
outcome: "failed",
error: error instanceof Error ? error.message : String(error)
})
);
res.writeHead(500);
res.end("failed");
}
});
server.listen(env.PORT, () => {
console.log(`gitea-pr-review-bot listening on :${env.PORT}`);
});
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
+35
View File
@@ -0,0 +1,35 @@
export type GiteaLabel = { name: string };
export type PullRequestInfo = {
number: number;
title: string;
body: string | null;
head: { sha: string; ref: string };
base: { ref: string };
labels?: GiteaLabel[];
requested_reviewers?: Array<{ login: string }>;
};
export type RepositoryInfo = {
owner: { login: string };
name: string;
full_name: string;
};
export type PullRequestWebhookPayload = {
action: string;
pull_request: PullRequestInfo;
repository: RepositoryInfo;
requested_reviewer?: { login: string };
sender?: { login?: string };
};
export type RoutedEvent =
| {
kind: "pr_opened";
payload: PullRequestWebhookPayload;
}
| {
kind: "review_requested";
payload: PullRequestWebhookPayload;
};
+23
View File
@@ -0,0 +1,23 @@
import { RoutedEvent, PullRequestWebhookPayload } from "../types/events.js";
export function routeEvent(input: {
eventName: string | undefined;
payload: PullRequestWebhookPayload;
botLogin: string;
}): RoutedEvent | null {
const { eventName, payload, botLogin } = input;
if (eventName === "pull_request" && payload.action === "opened") {
return { kind: "pr_opened", payload };
}
if (
eventName === "pull_request_review_request" &&
payload.action === "review_requested" &&
payload.requested_reviewer?.login === botLogin
) {
return { kind: "review_requested", payload };
}
return null;
}
+30
View File
@@ -0,0 +1,30 @@
import crypto from "node:crypto";
export function verifySignature(input: {
secret: string;
body: string;
signatureHeader?: string | null;
}): boolean {
const { secret, body, signatureHeader } = input;
if (!signatureHeader) {
return false;
}
const expected = crypto
.createHmac("sha256", secret)
.update(body, "utf8")
.digest("hex");
const provided = signatureHeader.startsWith("sha256=")
? signatureHeader.slice("sha256=".length)
: signatureHeader;
const expectedBuf = Buffer.from(expected);
const providedBuf = Buffer.from(provided);
if (expectedBuf.length !== providedBuf.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuf, providedBuf);
}