Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/api/src/api/activity/activity.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,28 @@ export class ActivityService {
return rows[0]?.headSha ?? null;
}

/**
* True while a pull request review of this repository and pull request is
* still running. The completed check alone lets a second event started
* before the first review ends launch a duplicate review: GitHub sends
* "opened" and a following "synchronize" seconds apart, and both would pass.
* A run left in "started" by a crash is reset to "failed" at boot.
*/
public async hasRunningPullRequestReview(repositoryId: number, targetIid: number): Promise<boolean> {
const [{ total }] = await db
.select({ total: count() })
.from(activityTable)
.where(
and(
eq(activityTable.repositoryId, repositoryId),
eq(activityTable.targetIid, targetIid),
eq(activityTable.type, "pr_review"),
eq(activityTable.status, "started"),
),
);
return total > 0;
}

Comment on lines +425 to +446

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR looks great! But I think checking in-flight PR review from database is not the best approach. Maybe storing the in-flight PRs in the memory and checking them from memory is better.

public async hasCompletedPullRequestReview(repositoryId: number, targetIid: number): Promise<boolean> {
const [{ total }] = await db
.select({ total: count() })
Expand Down
113 changes: 113 additions & 0 deletions apps/api/src/api/activity/pr-review-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { resolve } from "node:path";

// Isolate the real database from module mocks in other test files.
if (process.env.PROVAL_PR_REVIEW_GUARD_TEST_CHILD !== "1") {
it("passes pull request review guard integration in an isolated process", () => {
const result = Bun.spawnSync([process.execPath, "test", import.meta.path], {
env: { ...process.env, PROVAL_PR_REVIEW_GUARD_TEST_CHILD: "1", DB_FILE_NAME: ":memory:" },
stdout: "pipe",
stderr: "pipe",
});
if (result.exitCode !== 0) {
throw new Error(result.stdout.toString() + result.stderr.toString());
}
expect(result.exitCode).toBe(0);
}, 30000);
} else {
process.env.DB_FILE_NAME = ":memory:";
const { migrate } = await import("drizzle-orm/bun-sqlite/migrator");
const { activityTable, repositoryTable } = await import("@proval/db");
const { default: db } = await import("../../db/index.js");
// Loaded before the service: log.ts builds an ActivityService at module load.
await import("../../util/log.js");
const { ActivityService } = await import("./activity.service.js");

migrate(db, { migrationsFolder: resolve(import.meta.dir, "../../../../../packages/db/src/migration") });
const service = new ActivityService();

type ActivityType = "pr_review" | "pr_reply" | "issue_open" | "issue_reply";
type ActivityStatus = "started" | "completed" | "failed" | "canceled";

let repositoryId: number;

async function addActivity(
options: {
type?: ActivityType;
status?: ActivityStatus;
targetIid?: number;
repositoryId?: number;
} = {},
) {
await db.insert(activityTable).values({
repositoryId: options.repositoryId ?? repositoryId,
repositoryPath: "group/repo",
provider: "github",
modelName: "test-model",
type: options.type ?? "pr_review",
status: options.status ?? "started",
targetIid: options.targetIid ?? 8,
logVersion: "1",
});
}

beforeEach(async () => {
await db.delete(activityTable);
await db.delete(repositoryTable);
const [repository] = await db
.insert(repositoryTable)
.values({ path: "group/repo", provider: "github" })
.returning({ id: repositoryTable.id });
repositoryId = repository.id;
});

describe("hasRunningPullRequestReview", () => {
it("reports a review that is still in flight", async () => {
await addActivity({ status: "started" });

expect(await service.hasRunningPullRequestReview(repositoryId, 8)).toBe(true);
});

it("ignores a review that already ended, whatever its outcome", async () => {
for (const status of ["completed", "failed", "canceled"] as const) {
await db.delete(activityTable);
await addActivity({ status });

expect(await service.hasRunningPullRequestReview(repositoryId, 8)).toBe(false);
}
});

it("ignores another activity type, another pull request and another repository", async () => {
await addActivity({ type: "pr_reply", status: "started" });
await addActivity({ type: "issue_reply", status: "started" });
await addActivity({ targetIid: 9, status: "started" });

const [otherRepository] = await db
.insert(repositoryTable)
.values({ path: "group/other", provider: "github" })
.returning({ id: repositoryTable.id });
await addActivity({ repositoryId: otherRepository.id, status: "started" });

expect(await service.hasRunningPullRequestReview(repositoryId, 8)).toBe(false);
});
});

describe("hasCompletedPullRequestReview", () => {
it("stays false while the only review of that pull request is still running", async () => {
await addActivity({ status: "started" });

// This is the gap the running guard closes: two events seconds apart
// (opened, then a push) both see no completed review.
expect(await service.hasCompletedPullRequestReview(repositoryId, 8)).toBe(false);
expect(await service.hasRunningPullRequestReview(repositoryId, 8)).toBe(true);
});

it("becomes true once a review completed", async () => {
await addActivity({ status: "started" });
await addActivity({ status: "completed" });

expect(await service.hasCompletedPullRequestReview(repositoryId, 8)).toBe(true);
expect(await service.hasRunningPullRequestReview(repositoryId, 8)).toBe(true);
});
});
}
4 changes: 4 additions & 0 deletions apps/api/src/webhook/forgejo/forgejo.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ const handleForgejoPullRequestWebhook = async (
const activityService = new ActivityService();
const prNumber = pr.number;
const reviewMode = repository.prReviewOnPush;
if (await activityService.hasRunningPullRequestReview(repository.id, prNumber)) {
log(`Skipped: review already running (${prInfo})`, "Forgejo");
return new Response(JSON.stringify({ message: "Skipped: review already running" }), { status: 200 });
}

let hasCompleted: boolean | null = null;
if (reviewMode === "on_first_push") {
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/webhook/github/github.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ async function handlePullRequestWebhook(
if (accessSkip) return accessSkip;

const activityService = new ActivityService();
if (await activityService.hasRunningPullRequestReview(repository.id, prNumber)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚫️ [PROBLEM] Skipped synchronize in on_every_push is never re-reviewed

In on_every_push mode, a synchronize event that lands while a review is already in flight is skipped with a 200 and never revisited. The head-SHA dedup at apps/api/src/webhook/github/github.controller.ts:180 only looks at completed reviews (findLastReviewedHeadSha filters status = "completed" in apps/api/src/api/activity/activity.service.ts:416), and the completion path in apps/api/src/api/activity/activity.runner.ts:10-22 does not re-check whether the pushed head moved. So for the common "push, review starts, push again minutes later" loop, the second push's head receives no review until a third push arrives — before this change, that synchronize at least started a review. The same guard shape exists in gitlab.controller.ts:147 and forgejo.controller.ts:180.

Suggested direction: only apply the running-guard in on_first_push mode, or remember the skipped head and start a follow-up review when the in-flight one completes.

return new Response(JSON.stringify({ message: "Skipped: review already running" }), { status: 200 });
}

let hasCompleted: boolean | null = null;
if (reviewMode === "on_first_push") {
hasCompleted = await activityService.hasCompletedPullRequestReview(repository.id, prNumber);
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/webhook/gitlab/gitlab.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ const handleGitLabPullRequestWebhook: HandleGitLabPullRequestWebhook = async (

const activityService = new ActivityService();
const prIid = pullRequest.iid;
if (await activityService.hasRunningPullRequestReview(repository.id, prIid)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚫️ [PROBLEM] Guard is check-then-run; started row lands only after network I/O

The guard is a check-then-run with a wide window: it checks for a status = "started" row here, but that row is only inserted by activityService.start inside runWithActivity (apps/api/src/api/activity/activity.runner.ts:9), which runs fire-and-forget after awaited network I/O in the handler (in the GitHub controller: fetchPullRequestVersion at line 175, then fetchPullRequestChangedFileCount, then runWithActivity at line 208). Two near-simultaneous events — a webhook redelivery, or a push landing while the opened handler is still fetching — both pass this check and both launch reviews, which is the exact duplicate-launch behavior this PR aims to prevent.

Consider reserving the in-flight state synchronously at guard time (e.g. an in-process Set keyed by ${repositoryId}:${targetIid} inserted before the version fetches and cleared in runWithActivity's finally), or inserting the started activity row before the network I/O and reusing it as the run's row. This same window exists in the GitHub and Forgejo controllers.

return new Response(JSON.stringify({ message: "Skipped: review already running" }), { status: 200 });
}

let hasCompleted: boolean | null = null;
if (reviewMode === "on_first_push") {
Expand Down