Init
This commit is contained in:
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
gitea-pr-review-bot:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT:-8787}:8787"
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
Generated
+2217
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 : []
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user