diff --git a/src/cursor/review-agent.ts b/src/cursor/review-agent.ts index a7031ec..a51f7d3 100644 --- a/src/cursor/review-agent.ts +++ b/src/cursor/review-agent.ts @@ -7,23 +7,18 @@ type RunReviewInput = { prompt: string; timeoutMs: number; model?: string; - giteaBaseUrl: string; - owner: string; - repo: string; - headRef: string; correlationId?: string; }; export async function runCursorReview(input: RunReviewInput): Promise { - const repoUrl = buildRepoUrl(input.giteaBaseUrl, input.owner, input.repo); - try { const result = await withTimeout( Agent.prompt(input.prompt, { apiKey: input.apiKey, model: { id: input.model ?? "composer-2.5" }, + // No repo clone: PR diff and rules are embedded in the prompt from Gitea API. + // Cloud only supports connected GitHub/GitLab remotes, not private Gitea URLs. cloud: { - repos: [{ url: repoUrl, startingRef: input.headRef }], skipReviewerRequest: true } }), @@ -47,16 +42,18 @@ export async function runCursorReview(input: RunReviewInput): Promise(); + private readonly completed = new Map(); + private readonly inFlight = new Set(); constructor(private readonly ttlSeconds: number) {} @@ -14,21 +15,33 @@ export class DedupeStore { return `${input.owner}/${input.repo}#${input.prNumber}#${input.headSha}`; } - has(key: string): boolean { + tryAcquire(key: string): "acquired" | "completed" | "in_flight" { this.prune(); - return this.data.has(key); + if (this.completed.has(key)) { + return "completed"; + } + if (this.inFlight.has(key)) { + return "in_flight"; + } + this.inFlight.add(key); + return "acquired"; } - mark(key: string): void { + complete(key: string): void { const expiresAt = Date.now() + this.ttlSeconds * 1000; - this.data.set(key, { expiresAt }); + this.completed.set(key, { expiresAt }); + this.inFlight.delete(key); + } + + release(key: string): void { + this.inFlight.delete(key); } private prune(): void { const now = Date.now(); - for (const [k, record] of this.data.entries()) { + for (const [key, record] of this.completed.entries()) { if (record.expiresAt <= now) { - this.data.delete(k); + this.completed.delete(key); } } } diff --git a/src/run/review-runner.ts b/src/run/review-runner.ts index 8a83ce9..f0ec4fc 100644 --- a/src/run/review-runner.ts +++ b/src/run/review-runner.ts @@ -28,19 +28,51 @@ export async function runReview(input: { prNumber, headSha }); - if (input.dedupe.has(dedupeKey)) { + const dedupeSlot = input.dedupe.tryAcquire(dedupeKey); + if (dedupeSlot !== "acquired") { log("info", "Review skipped: duplicate webhook", { correlation_id: input.correlationId, owner, repo, pr_number: prNumber, - head_sha: headSha + head_sha: headSha, + reason: dedupeSlot }); return "skipped"; } - input.dedupe.mark(dedupeKey); const gitea = new GiteaClient(input.env.GITEA_BASE_URL, input.env.GITEA_TOKEN, 30000); + try { + return await executeReview({ + input, + gitea, + owner, + repo, + prNumber, + headSha, + dedupeKey + }); + } catch (error) { + input.dedupe.release(dedupeKey); + throw error; + } +} + +async function executeReview(params: { + input: { + env: Env; + event: RoutedEvent; + dedupe: DedupeStore; + correlationId: string; + }; + gitea: GiteaClient; + owner: string; + repo: string; + prNumber: number; + headSha: string; + dedupeKey: string; +}): Promise<"skipped" | "success"> { + const { input, gitea, owner, repo, prNumber, headSha, dedupeKey } = params; const pull = await retry({ fn: () => gitea.getPull(owner, repo, prNumber), retries: 2, @@ -69,6 +101,7 @@ export async function runReview(input: { botLogin: input.env.GITEA_BOT_LOGIN }); if (!should.process) { + input.dedupe.release(dedupeKey); log("info", "Review skipped by policy", { correlation_id: input.correlationId, owner, @@ -103,10 +136,6 @@ export async function runReview(input: { prompt, timeoutMs: input.env.REVIEW_TIMEOUT_MS, model: repoConfig.model, - giteaBaseUrl: input.env.GITEA_BASE_URL, - owner, - repo, - headRef: pull.head.ref, correlationId: input.correlationId }), retries: 2, @@ -162,6 +191,8 @@ export async function runReview(input: { correlationId: input.correlationId }); + input.dedupe.complete(dedupeKey); + log("info", "Review completed", { correlation_id: input.correlationId, owner,