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
+11
View File
@@ -0,0 +1,11 @@
CURSOR_API_KEY=
GITEA_TOKEN=
GITEA_BASE_URL=https://gitea.example.com
GITEA_BOT_LOGIN=comedykit-pr-bot
WEBHOOK_SECRET=
PORT=8787
DEFAULT_BASE_BRANCH=main
MAX_INLINE_COMMENTS=5
REVIEW_TIMEOUT_MS=120000
DEDUPE_TTL_SECONDS=1800
+12
View File
@@ -0,0 +1,12 @@
node_modules/
dist/
.env
.env.local
.env.*.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
COPY scripts ./scripts
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
EXPOSE 8787
CMD ["node", "dist/server.js"]
+177
View File
@@ -0,0 +1,177 @@
# gitea-pr-review-bot
Central webhook service that reviews Gitea pull requests using the Cursor SDK and posts a full Gitea review (summary + inline comments).
This bot is designed to run once for many repositories in `Bram/*` instead of duplicating workflows in every repo.
## What This Bot Does
- Listens to Gitea webhook events.
- Triggers on:
- `pull_request` with action `opened`
- `pull_request_review_request` with action `review_requested` when requested reviewer is the bot user
- Loads PR metadata and changed files from Gitea.
- Builds a review prompt (including optional repo-specific rule files).
- Calls Cursor Cloud Agent (`Agent.prompt`) for structured review output.
- Validates and posts a single consolidated Gitea review.
- Removes itself from requested reviewers after successful review.
- Prevents duplicate processing by dedupe key:
- `{owner}/{repo}#{pr_number}#{head_sha}`
## High-Level Flow
1. `src/server.ts` receives webhook payload.
2. `src/webhook/verify-signature.ts` validates HMAC (`WEBHOOK_SECRET`).
3. `src/webhook/event-router.ts` accepts or ignores event by type/action.
4. `src/run/review-runner.ts` orchestrates full review run.
5. `src/config/load-repo-config.ts` loads optional per-repo overrides.
6. `src/gitea/client.ts` fetches PR + files and writes reviews.
7. `src/prompt/build-review-prompt.ts` assembles review instructions/context.
8. `src/cursor/review-agent.ts` invokes Cursor SDK in cloud mode.
9. `src/cursor/review-schema.ts` validates structured JSON response.
10. `src/gitea/review-api.ts` posts review and handles inline comment fallback.
11. `src/gitea/reviewer-api.ts` removes bot reviewer from PR.
## Project Structure
### Server and Webhook Layer
- `src/server.ts`
- HTTP server, `/healthz`, `/webhooks/gitea`
- Signature check + event routing + response codes
- `src/webhook/verify-signature.ts`
- SHA256 HMAC validation with timing-safe compare
- `src/webhook/event-router.ts`
- Converts raw webhook headers/payload into supported routed events
### Domain Logic
- `src/domain/should-process-event.ts`
- Loop guard (skip bot-originated events)
- Repo enable/disable check
- Label skip logic
- Base branch filtering
- `src/domain/dedupe-store.ts`
- In-memory TTL dedupe store for idempotency
### Gitea Integration
- `src/gitea/client.ts`
- Typed wrapper around Gitea REST endpoints
- Pull details/files/reviews, create/delete review, patch pull
- Optional content-file loading from repository
- `src/gitea/review-api.ts`
- Delete prior bot reviews
- Post one consolidated review
- Validate inline comments against changed-file paths and positions
- Summary-only fallback if inline set is invalid
- `src/gitea/reviewer-api.ts`
- Remove bot from requested reviewers list
### Cursor Integration
- `src/cursor/review-agent.ts`
- Calls Cursor SDK `Agent.prompt` in cloud runtime
- Enforces review timeout
- Extracts text from SDK response formats
- `src/cursor/review-schema.ts`
- Zod schema for strict review JSON contract:
- `verdict`, `event`, `body`, `comments[]`
### Prompting and Config
- `src/prompt/build-review-prompt.ts`
- Builds complete PR review prompt from title/body/files/patches/rules
- `src/config/load-repo-config.ts`
- Reads `.gitea/pr-review-bot.yml` when present
- Loads optional repo-local rule files:
- `AGENTS.md`
- `docs/pr-review.md`
- `.cursor/skills/pr-review/SKILL.md`
- `.cursor/skills/pr-review/gitea.md`
### Orchestration and Utilities
- `src/run/review-runner.ts`
- Main end-to-end review execution
- Dedupe, retry wrappers, posting, reviewer self-removal
- `src/run/retry.ts`
- Exponential backoff retry helper for transient failures
- `src/types/events.ts`
- Gitea webhook payload typings
### Scripts and Deployment
- `scripts/dry-run.ts`
- Manual run by `owner/repo/pr`
- `Dockerfile`
- Production container image
- `docker-compose.yml`
- Local/hosted compose deployment
- `docs/setup.md`
- Installation and webhook setup
- `docs/operations.md`
- Runtime behavior, failure handling, rollback
## Environment Variables
Required:
- `CURSOR_API_KEY`
- `GITEA_TOKEN`
- `GITEA_BASE_URL`
- `GITEA_BOT_LOGIN`
- `WEBHOOK_SECRET`
- `PORT`
Optional:
- `DEFAULT_BASE_BRANCH` (default: `main`)
- `MAX_INLINE_COMMENTS` (default: `5`)
- `REVIEW_TIMEOUT_MS` (default: `120000`)
- `DEDUPE_TTL_SECONDS` (default: `1800`)
See `.env.example`.
## Review Output Contract
The Cursor response must be strict JSON:
- `verdict`: `approve | request_changes | comment`
- `event`: `APPROVE | REQUEST_CHANGES | COMMENT`
- `body`: string
- `comments`: array of:
- `path` (changed file path)
- `new_position` (integer >= 1)
- `body` (comment text)
Post-validation rules:
- Invalid path/position in inline comments => post summary-only review.
- Inline comments are clamped to configured maximum.
## Getting Started
1. Copy env template:
- `cp .env.example .env`
2. Fill secrets/tokens.
3. Install:
- `npm install`
4. Run locally:
- `npm run dev`
5. Verify health:
- `curl http://localhost:8787/healthz`
## Useful Commands
- Type check: `npm run check`
- Build: `npm run build`
- Start built app: `npm run start`
- Dry run: `npm run dry-run -- <owner> <repo> <pr_number> [head_sha]`
## Operational Notes
- Keep bot credentials scoped to least privilege.
- Do not log tokens or raw auth headers.
- Dedupe is in-memory (resets on restart).
- Best deployment model is a central service with org-level webhook and per-repo opt-out config.
+8
View File
@@ -0,0 +1,8 @@
services:
gitea-pr-review-bot:
build: .
ports:
- "${PORT:-8787}:8787"
env_file:
- .env
restart: unless-stopped
+39
View File
@@ -0,0 +1,39 @@
# Operations Runbook
## Behavior
- Processes only:
- `pull_request` with action `opened`
- `pull_request_review_request` with action `review_requested` and reviewer matching bot login
- Idempotency key: `{owner}/{repo}#{pr_number}#{head_sha}`
- Removes bot from reviewers after a successful review post
## Logging
Structured logs include:
- `correlation_id`
- `owner`
- `repo`
- `pr_number`
- `head_sha`
- `outcome` (`skipped`, `success`, `failed`)
Never log token values or raw authorization headers.
## Failure handling
- Signature validation failure: request rejected with 401.
- Schema validation failure from Cursor output: request fails and review is not posted.
- Invalid inline comments after validation: service posts summary review only (no inline comments).
## Retry guidance
- Safe to replay the same webhook delivery; dedupe blocks duplicates within TTL.
- For transient outages (Cursor/Gitea), re-deliver webhook from Gitea UI.
## Rollback
1. Disable org/repo webhook.
2. Stop deployment (`docker compose down`).
3. Re-enable webhook after fix and redeploy (`docker compose up -d --build`).
+52
View File
@@ -0,0 +1,52 @@
# Setup
## 1) Create the repository and bot user
1. Create `Bram/gitea-pr-review-bot`.
2. Create the dedicated Gitea user `comedykit-pr-bot`.
3. Create a PAT for that user with scopes:
- `write:repository`
- `write:issue`
## 2) Configure secrets
Create `.env` from `.env.example` and set:
- `CURSOR_API_KEY`
- `GITEA_TOKEN`
- `GITEA_BASE_URL`
- `GITEA_BOT_LOGIN`
- `WEBHOOK_SECRET`
- `PORT`
## 3) Install and run locally
```bash
npm install
npm run dev
```
Health check:
```bash
curl http://localhost:8787/healthz
```
## 4) Configure Gitea webhook
Add an organization webhook for `Bram` (or per repo for pilot) with:
- URL: `https://<bot-host>/webhooks/gitea`
- Secret: matches `WEBHOOK_SECRET`
- Events:
- `pull_request`
- `pull_request_review_request`
## 5) Pilot rollout
Start with:
- `comedykit-frontend-new`
- `comedykit-backend-new`
After validation, enable org webhook for remaining `Bram/*` repositories and use `.gitea/pr-review-bot.yml` for per-repo opt-out.
+2217
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "gitea-pr-review-bot",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Central webhook service for PR reviews using Cursor SDK and Gitea API.",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/server.ts",
"start": "node dist/server.js",
"dry-run": "tsx scripts/dry-run.ts",
"check": "tsc --noEmit"
},
"dependencies": {
"@cursor/sdk": "latest",
"yaml": "^2.8.1",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/node": "^24.3.1",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
}
}
+51
View File
@@ -0,0 +1,51 @@
import { loadEnv } from "../src/config/env.js";
import { DedupeStore } from "../src/domain/dedupe-store.js";
import { runReview } from "../src/run/review-runner.js";
import { RoutedEvent } from "../src/types/events.js";
async function main(): Promise<void> {
const [owner, repo, prRaw, headSha = "dry-run-sha"] = process.argv.slice(2);
if (!owner || !repo || !prRaw) {
throw new Error("Usage: npm run dry-run -- <owner> <repo> <pr_number> [head_sha]");
}
const prNumber = Number(prRaw);
if (Number.isNaN(prNumber)) {
throw new Error("pr_number must be a number");
}
const env = loadEnv();
const event: RoutedEvent = {
kind: "review_requested",
payload: {
action: "review_requested",
pull_request: {
number: prNumber,
title: "dry run",
body: "",
head: { sha: headSha, ref: "dry-run-head" },
base: { ref: env.DEFAULT_BASE_BRANCH },
labels: []
},
repository: {
owner: { login: owner },
name: repo,
full_name: `${owner}/${repo}`
},
requested_reviewer: { login: env.GITEA_BOT_LOGIN }
}
};
const dedupe = new DedupeStore(env.DEDUPE_TTL_SECONDS);
const outcome = await runReview({
env,
event,
dedupe,
correlationId: "dry-run"
});
console.log(`dry-run outcome: ${outcome}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+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);
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*.ts",
"scripts/**/*.ts"
]
}