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
51 changes: 51 additions & 0 deletions app/tasks/gh-design/filterReviewers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "bun:test";
import { filterReviewers } from "./filterReviewers";

describe("filterReviewers", () => {
const REVIEWERS = ["PabloWiedemann", "AliceDev"];

Comment on lines +1 to +6

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

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

This test imports filterReviewers from ./gh-design, but gh-design.ts imports @/src/db which performs a top-level MongoClient initialization (top-level await) and will try to connect during tests unless @/src/db is mocked. This can make the unit test suite hang/fail when MONGODB_URI isn’t set. Consider either moving filterReviewers into a small side-effect-free module (e.g. filterReviewers.ts) that both the task and the test import, or change the spec to mock.module("@/src/db", ...) before dynamically importing ./gh-design.

Suggested change
import { describe, expect, it } from "bun:test";
import { filterReviewers } from "./gh-design";
describe("filterReviewers", () => {
const REVIEWERS = ["PabloWiedemann", "AliceDev"];
import { beforeAll, describe, expect, it, mock } from "bun:test";
let filterReviewers: typeof import("./gh-design")["filterReviewers"];
describe("filterReviewers", () => {
const REVIEWERS = ["PabloWiedemann", "AliceDev"];
beforeAll(async () => {
mock.module("@/src/db", () => ({
db: {},
}));
({ filterReviewers } = await import("./gh-design"));
});

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in cee9996 — extracted filterReviewers() into its own side-effect-free module (filterReviewers.ts). Test now imports directly from it without triggering any DB initialization. Test time dropped from ~1126ms to ~148ms.

it("excludes the PR author from both lists", () => {
const result = filterReviewers(REVIEWERS, "PabloWiedemann");
expect(result.requestReviewers).toEqual(["AliceDev"]);
expect(result.newReviewers).toEqual(["AliceDev"]);
});

it("returns all reviewers when author is not in the list", () => {
const result = filterReviewers(REVIEWERS, "SomeoneElse");
expect(result.requestReviewers).toEqual(["PabloWiedemann", "AliceDev"]);
expect(result.newReviewers).toEqual(["PabloWiedemann", "AliceDev"]);
});

it("excludes already-requested reviewers from newReviewers only", () => {
const result = filterReviewers(REVIEWERS, "SomeoneElse", ["PabloWiedemann"]);
expect(result.requestReviewers).toEqual(["PabloWiedemann", "AliceDev"]);
expect(result.newReviewers).toEqual(["AliceDev"]);
});

it("returns empty newReviewers when all are already requested", () => {
const result = filterReviewers(REVIEWERS, "SomeoneElse", ["PabloWiedemann", "AliceDev"]);
expect(result.requestReviewers).toEqual(["PabloWiedemann", "AliceDev"]);
expect(result.newReviewers).toEqual([]);
});

it("returns empty lists when author is the only reviewer", () => {
const result = filterReviewers(["PabloWiedemann"], "PabloWiedemann");
expect(result.requestReviewers).toEqual([]);
expect(result.newReviewers).toEqual([]);
});

it("handles undefined alreadyRequested as no-one requested yet", () => {
const result = filterReviewers(REVIEWERS, "SomeoneElse", undefined);
expect(result.newReviewers).toEqual(["PabloWiedemann", "AliceDev"]);
});

it("compares usernames case-insensitively", () => {
const result = filterReviewers(REVIEWERS, "pablowiedemann");
expect(result.requestReviewers).toEqual(["AliceDev"]);
});

it("matches already-requested reviewers case-insensitively", () => {
const result = filterReviewers(REVIEWERS, "SomeoneElse", ["pablowiedemann"]);
expect(result.newReviewers).toEqual(["AliceDev"]);
});
});
17 changes: 17 additions & 0 deletions app/tasks/gh-design/filterReviewers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Compute eligible reviewers for a PR.
* Returns `requestReviewers` (all reviewers minus the PR author) and
* `newReviewers` (eligible reviewers not yet requested).
* GitHub usernames are case-insensitive, so comparisons are normalized.
*/
export function filterReviewers(
allReviewers: string[],
prAuthor: string,
alreadyRequested?: string[],
): { requestReviewers: string[]; newReviewers: string[] } {
const normalizedAuthor = prAuthor.toLowerCase();
const normalizedRequested = new Set(alreadyRequested?.map((r) => r.toLowerCase()) ?? []);
const requestReviewers = allReviewers.filter((e) => e.toLowerCase() !== normalizedAuthor);
const newReviewers = requestReviewers.filter((e) => !normalizedRequested.has(e.toLowerCase()));
return { requestReviewers, newReviewers };
}
47 changes: 32 additions & 15 deletions app/tasks/gh-design/gh-design.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
planDesignCommentNotification,
} from "./slackNotifications";
import { slackMessageUrlParse, slackMessageUrlStringify } from "./slackMessageUrlParse";
import { filterReviewers } from "./filterReviewers";
const tlog = createTimeLogger();

/**
Expand Down Expand Up @@ -254,21 +255,37 @@ export async function runGithubDesignTask() {
});

if (task.state === "open") {
if (
task.type === "pull_request" &&
REQUEST_REVIEWERS.some((e) => !task.reviewers?.includes(e))
) {
const requestReviewers = REQUEST_REVIEWERS;
const newReviewers = requestReviewers.filter((e) => !task.reviewers?.includes(e));
tlog(`Requesting reviewers: ${newReviewers.join(", ")}`);
if (!dryRun) {
await gh.pulls.requestReviewers({
owner,
repo,
pull_number: issue_number,
reviewers: newReviewers,
});
task = await saveGithubDesignTask(url, { reviewers: requestReviewers });
if (task.type === "pull_request") {
const { requestReviewers, newReviewers } = filterReviewers(
REQUEST_REVIEWERS,
task.user,
task.reviewers,
);
if (newReviewers.length > 0) {
tlog(`Requesting reviewers: ${newReviewers.join(", ")}`);
if (!dryRun) {
let reviewersRequested = false;
try {
await gh.pulls.requestReviewers({
owner,
repo,
pull_number: issue_number,
reviewers: newReviewers,
});
reviewersRequested = true;
} catch (err: unknown) {
// GitHub may return 422 when a requested reviewer cannot be added,
// such as when they are not a collaborator or cannot be requested.
// We log but don't persist, so the request will be retried on the
// next run (the reviewer may become eligible later).
const status = (err as { status?: number })?.status;
if (status !== 422) throw err;
tlog(`Reviewer request rejected (422): ${err}`);
}
Comment on lines +277 to +284

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

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

The 422 handler comment says the attempt is recorded to avoid retrying on every 5‑minute run, but the code does not persist anything on 422 (and also doesn’t persist when newReviewers is empty). As a result, a persistent 422 (e.g., non-collaborator) will be retried every run, and the comment/PR description are misleading. Either (a) update the comment/PR description to reflect that 422s will be retried, or (b) persist a separate “attempted/rejected reviewers” state (or an error flag) so retries are actually suppressed without marking reviewers as successfully requested.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 29caf1f — updated the comment to accurately state that 422 rejections are retried on subsequent runs (not suppressed), since the reviewer may become eligible later. This is intentional: the primary self-review case is already filtered out, and remaining 422s (e.g., non-collaborator) are transient conditions worth retrying.

if (reviewersRequested) {
task = await saveGithubDesignTask(url, { reviewers: requestReviewers });
}
Comment on lines +258 to +287

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

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

PR description says the task will persist the attempted reviewers list even when the filtered list is empty to avoid retrying on every schedule tick, but this implementation only persists reviewers after a successful requestReviewers call and does nothing when newReviewers.length === 0. Either update the PR description to match the behavior, or persist some state when newReviewers is empty (without marking reviewers as successfully requested) if suppressing future work is still desired.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — updated PR description to accurately describe the persistence behavior: reviewers are only saved on successful API call, and 422s are retried on subsequent runs.

}
}
}

Expand Down
Loading