Repository navigation
Do not start a second review while one is running #79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -158,6 +158,10 @@ async function handlePullRequestWebhook( | |
| if (accessSkip) return accessSkip; | ||
|
|
||
| const activityService = new ActivityService(); | ||
| if (await activityService.hasRunningPullRequestReview(repository.id, prNumber)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚫️ [PROBLEM] Skipped synchronize in on_every_push is never re-reviewed In Suggested direction: only apply the running-guard in |
||
| 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); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -144,6 +144,9 @@ const handleGitLabPullRequestWebhook: HandleGitLabPullRequestWebhook = async ( | |
|
|
||
| const activityService = new ActivityService(); | ||
| const prIid = pullRequest.iid; | ||
| if (await activityService.hasRunningPullRequestReview(repository.id, prIid)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Consider reserving the in-flight state synchronously at guard time (e.g. an in-process |
||
| return new Response(JSON.stringify({ message: "Skipped: review already running" }), { status: 200 }); | ||
| } | ||
|
|
||
| let hasCompleted: boolean | null = null; | ||
| if (reviewMode === "on_first_push") { | ||
|
|
||
There was a problem hiding this comment.
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.