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; prompt: string;
timeoutMs: number; timeoutMs: number;
model?: string; model?: string;
giteaBaseUrl: string;
owner: string;
repo: string;
headRef: string;
correlationId?: string; correlationId?: string;
}; };
export async function runCursorReview(input: RunReviewInput): Promise<ReviewResult> { export async function runCursorReview(input: RunReviewInput): Promise<ReviewResult> {
const repoUrl = buildRepoUrl(input.giteaBaseUrl, input.owner, input.repo);
try { try {
const result = await withTimeout( const result = await withTimeout(
Agent.prompt(input.prompt, { Agent.prompt(input.prompt, {
apiKey: input.apiKey, apiKey: input.apiKey,
model: { id: input.model ?? "composer-2.5" }, 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: { cloud: {
repos: [{ url: repoUrl, startingRef: input.headRef }],
skipReviewerRequest: true skipReviewerRequest: true
} }
}), }),
@@ -47,16 +42,18 @@ export async function runCursorReview(input: RunReviewInput): Promise<ReviewResu
return parseReviewResult(parsed); return parseReviewResult(parsed);
} catch (error) { } catch (error) {
if (error instanceof CursorAgentError) { 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 new Error(`Cursor SDK startup failed: ${error.message}`);
} }
throw error; throw error;
} }
} }
function buildRepoUrl(baseUrl: string, owner: string, repo: string): string {
return `${baseUrl.replace(/\/$/, "")}/${owner}/${repo}`;
}
function extractResponseText(result: string | undefined): string { function extractResponseText(result: string | undefined): string {
if (!result?.trim()) { if (!result?.trim()) {
throw new Error("Cursor review returned empty result text"); throw new Error("Cursor review returned empty result text");
+20 -7
View File
@@ -1,7 +1,8 @@
type DedupeRecord = { expiresAt: number }; type DedupeRecord = { expiresAt: number };
export class DedupeStore { 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) {} constructor(private readonly ttlSeconds: number) {}
@@ -14,21 +15,33 @@ export class DedupeStore {
return `${input.owner}/${input.repo}#${input.prNumber}#${input.headSha}`; return `${input.owner}/${input.repo}#${input.prNumber}#${input.headSha}`;
} }
has(key: string): boolean { tryAcquire(key: string): "acquired" | "completed" | "in_flight" {
this.prune(); 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; 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 { private prune(): void {
const now = Date.now(); const now = Date.now();
for (const [k, record] of this.data.entries()) { for (const [key, record] of this.completed.entries()) {
if (record.expiresAt <= now) { 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, prNumber,
headSha headSha
}); });
if (input.dedupe.has(dedupeKey)) { const dedupeSlot = input.dedupe.tryAcquire(dedupeKey);
if (dedupeSlot !== "acquired") {
log("info", "Review skipped: duplicate webhook", { log("info", "Review skipped: duplicate webhook", {
correlation_id: input.correlationId, correlation_id: input.correlationId,
owner, owner,
repo, repo,
pr_number: prNumber, pr_number: prNumber,
head_sha: headSha head_sha: headSha,
reason: dedupeSlot
}); });
return "skipped"; return "skipped";
} }
input.dedupe.mark(dedupeKey);
const gitea = new GiteaClient(input.env.GITEA_BASE_URL, input.env.GITEA_TOKEN, 30000); 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({ const pull = await retry({
fn: () => gitea.getPull(owner, repo, prNumber), fn: () => gitea.getPull(owner, repo, prNumber),
retries: 2, retries: 2,
@@ -69,6 +101,7 @@ export async function runReview(input: {
botLogin: input.env.GITEA_BOT_LOGIN botLogin: input.env.GITEA_BOT_LOGIN
}); });
if (!should.process) { if (!should.process) {
input.dedupe.release(dedupeKey);
log("info", "Review skipped by policy", { log("info", "Review skipped by policy", {
correlation_id: input.correlationId, correlation_id: input.correlationId,
owner, owner,
@@ -103,10 +136,6 @@ export async function runReview(input: {
prompt, prompt,
timeoutMs: input.env.REVIEW_TIMEOUT_MS, timeoutMs: input.env.REVIEW_TIMEOUT_MS,
model: repoConfig.model, model: repoConfig.model,
giteaBaseUrl: input.env.GITEA_BASE_URL,
owner,
repo,
headRef: pull.head.ref,
correlationId: input.correlationId correlationId: input.correlationId
}), }),
retries: 2, retries: 2,
@@ -162,6 +191,8 @@ export async function runReview(input: {
correlationId: input.correlationId correlationId: input.correlationId
}); });
input.dedupe.complete(dedupeKey);
log("info", "Review completed", { log("info", "Review completed", {
correlation_id: input.correlationId, correlation_id: input.correlationId,
owner, owner,