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