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:
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user