Init
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user