Fix Cursor cloud validation and dedupe after failed reviews.

Use a no-repo cloud agent since Gitea remotes are not supported for clone, and only mark dedupe complete on success so review_requested can retry.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Daan Schouteden
2026-06-03 11:05:21 +02:00
parent 84db121a4c
commit 28488d0be9
3 changed files with 66 additions and 25 deletions
+8 -11
View File
@@ -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<ReviewResult> {
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<ReviewResu
return parseReviewResult(parsed);
} catch (error) {
if (error instanceof CursorAgentError) {
log("error", "Cursor SDK error", {
correlation_id: input.correlationId,
message: error.message,
code: "code" in error ? String(error.code) : undefined,
is_retryable: "isRetryable" in error ? error.isRetryable : undefined
});
throw new Error(`Cursor SDK startup failed: ${error.message}`);
}
throw error;
}
}
function buildRepoUrl(baseUrl: string, owner: string, repo: string): string {
return `${baseUrl.replace(/\/$/, "")}/${owner}/${repo}`;
}
function extractResponseText(result: string | undefined): string {
if (!result?.trim()) {
throw new Error("Cursor review returned empty result text");
+20 -7
View File
@@ -1,7 +1,8 @@
type DedupeRecord = { expiresAt: number };
export class DedupeStore {
private readonly data = new Map<string, DedupeRecord>();
private readonly completed = new Map<string, DedupeRecord>();
private readonly inFlight = new Set<string>();
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);
}
}
}
+38 -7
View File
@@ -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,