Skip to content

Commit 71cc6f0

Browse files
authored
fix(rereview): add robust team slug fallback (#24)
Normalize configured rereview team slugs and fall back between aireview/ai-review on 422 errors. Reuse this logic in both review and mention handlers so rereview triggers stay best-effort but resilient.
1 parent 782ae3c commit 71cc6f0

File tree

5 files changed

+204
-61
lines changed

5 files changed

+204
-61
lines changed

src/handlers/mention.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,6 +1319,7 @@ describe("createMentionHandler rereview command", () => {
13191319
base: { ref: "main" },
13201320
},
13211321
}),
1322+
listRequestedReviewers: async () => ({ data: { users: [], teams: [] } }),
13221323
requestReviewers: async (params: { team_reviewers: string[] }) => {
13231324
requestedTeams = params.team_reviewers;
13241325
return { data: {} };

src/handlers/mention.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { buildMentionContext } from "../execution/mention-context.ts";
3131
import { buildMentionPrompt } from "../execution/mention-prompt.ts";
3232
import { classifyError, formatErrorComment, postOrUpdateErrorComment } from "../lib/errors.ts";
3333
import { wrapInDetails } from "../lib/formatting.ts";
34+
import { requestRereviewTeamBestEffort } from "./rereview-team.ts";
3435

3536
/**
3637
* Create the mention handler and register it with the event router.
@@ -580,21 +581,15 @@ export function createMentionHandler(deps: {
580581
mention.prNumber !== undefined &&
581582
(normalizedQuestion === "review" || normalizedQuestion === "recheck")
582583
) {
583-
const rereviewTeam = (config.review.uiRereviewTeam ?? "").trim() || "aireview";
584-
585-
try {
586-
await octokit.rest.pulls.requestReviewers({
587-
owner: mention.owner,
588-
repo: mention.repo,
589-
pull_number: mention.prNumber,
590-
team_reviewers: [rereviewTeam],
591-
});
592-
} catch (err) {
593-
logger.warn(
594-
{ err, prNumber: mention.prNumber, rereviewTeam },
595-
"Failed to request ui rereview team for rereview (best-effort)",
596-
);
597-
}
584+
const configuredTeam = (config.review.uiRereviewTeam ?? "").trim() || "aireview";
585+
await requestRereviewTeamBestEffort({
586+
octokit,
587+
owner: mention.owner,
588+
repo: mention.repo,
589+
prNumber: mention.prNumber,
590+
configuredTeam,
591+
logger,
592+
});
598593

599594
return;
600595
}

src/handlers/rereview-team.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { Logger } from "pino";
3+
import {
4+
buildRereviewTeamCandidates,
5+
requestRereviewTeamBestEffort,
6+
} from "./rereview-team.ts";
7+
8+
function createNoopLogger(): Logger {
9+
return {
10+
fatal: () => undefined,
11+
error: () => undefined,
12+
warn: () => undefined,
13+
info: () => undefined,
14+
debug: () => undefined,
15+
trace: () => undefined,
16+
silent: () => undefined,
17+
} as unknown as Logger;
18+
}
19+
20+
describe("rereview-team helpers", () => {
21+
test("buildRereviewTeamCandidates normalizes owner/team input and adds alias", () => {
22+
expect(buildRereviewTeamCandidates("xbmc/aireview")).toEqual(["aireview", "ai-review"]);
23+
expect(buildRereviewTeamCandidates("ai-review")).toEqual(["ai-review", "aireview"]);
24+
});
25+
26+
test("requestRereviewTeamBestEffort skips request when team already present", async () => {
27+
let requestCalls = 0;
28+
const result = await requestRereviewTeamBestEffort({
29+
octokit: {
30+
rest: {
31+
pulls: {
32+
listRequestedReviewers: async () => ({
33+
data: { users: [], teams: [{ slug: "aireview", name: "aireview" }] },
34+
}),
35+
requestReviewers: async () => {
36+
requestCalls += 1;
37+
return { data: {} };
38+
},
39+
},
40+
},
41+
},
42+
owner: "xbmc",
43+
repo: "kodiai",
44+
prNumber: 1,
45+
configuredTeam: "aireview",
46+
logger: createNoopLogger(),
47+
});
48+
49+
expect(result.alreadyRequested).toBe(true);
50+
expect(result.requestedTeam).toBeUndefined();
51+
expect(requestCalls).toBe(0);
52+
});
53+
54+
test("requestRereviewTeamBestEffort falls back from aireview to ai-review on 422", async () => {
55+
const attempted: string[] = [];
56+
57+
const result = await requestRereviewTeamBestEffort({
58+
octokit: {
59+
rest: {
60+
pulls: {
61+
listRequestedReviewers: async () => ({ data: { users: [], teams: [] } }),
62+
requestReviewers: async (params: { team_reviewers: string[] }) => {
63+
const slug = params.team_reviewers[0] ?? "";
64+
attempted.push(slug);
65+
if (slug === "aireview") {
66+
const err = new Error("Validation failed") as Error & { status: number };
67+
err.status = 422;
68+
throw err;
69+
}
70+
return { data: {} };
71+
},
72+
},
73+
},
74+
},
75+
owner: "xbmc",
76+
repo: "kodiai",
77+
prNumber: 2,
78+
configuredTeam: "aireview",
79+
logger: createNoopLogger(),
80+
});
81+
82+
expect(attempted).toEqual(["aireview", "ai-review"]);
83+
expect(result.requestedTeam).toBe("ai-review");
84+
expect(result.alreadyRequested).toBe(false);
85+
});
86+
});

src/handlers/rereview-team.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { Logger } from "pino";
2+
3+
function normalizeConfiguredTeam(team: string): string {
4+
const trimmed = team.trim().toLowerCase();
5+
if (trimmed.length === 0) return "";
6+
const lastSlash = trimmed.lastIndexOf("/");
7+
return lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed;
8+
}
9+
10+
export function buildRereviewTeamCandidates(team: string): string[] {
11+
const primary = normalizeConfiguredTeam(team);
12+
if (primary.length === 0) return [];
13+
14+
const candidates = new Set<string>([primary]);
15+
if (primary === "ai-review") candidates.add("aireview");
16+
if (primary === "aireview") candidates.add("ai-review");
17+
return Array.from(candidates);
18+
}
19+
20+
function getStatusCode(err: unknown): number | undefined {
21+
if (typeof err !== "object" || err === null) return undefined;
22+
const value = (err as { status?: unknown }).status;
23+
return typeof value === "number" ? value : undefined;
24+
}
25+
26+
export async function requestRereviewTeamBestEffort(options: {
27+
octokit: {
28+
rest: {
29+
pulls: {
30+
listRequestedReviewers: (params: {
31+
owner: string;
32+
repo: string;
33+
pull_number: number;
34+
}) => Promise<{ data: { teams?: Array<{ slug?: string | null; name?: string | null }> } }>;
35+
requestReviewers: (params: {
36+
owner: string;
37+
repo: string;
38+
pull_number: number;
39+
team_reviewers: string[];
40+
}) => Promise<unknown>;
41+
};
42+
};
43+
};
44+
owner: string;
45+
repo: string;
46+
prNumber: number;
47+
configuredTeam: string;
48+
logger: Logger;
49+
}): Promise<{ requestedTeam?: string; alreadyRequested: boolean }> {
50+
const { octokit, owner, repo, prNumber, configuredTeam, logger } = options;
51+
const candidates = buildRereviewTeamCandidates(configuredTeam);
52+
if (candidates.length === 0) return { alreadyRequested: false };
53+
54+
try {
55+
const requested = await octokit.rest.pulls.listRequestedReviewers({
56+
owner,
57+
repo,
58+
pull_number: prNumber,
59+
});
60+
61+
const already = (requested.data.teams ?? []).some((team) => {
62+
const slug = (team.slug ?? "").trim().toLowerCase();
63+
const name = (team.name ?? "").trim().toLowerCase();
64+
return candidates.includes(slug) || candidates.includes(name);
65+
});
66+
if (already) return { alreadyRequested: true };
67+
} catch (err) {
68+
logger.warn(
69+
{ err, owner, repo, prNumber, configuredTeam },
70+
"Failed to list requested reviewers; attempting rereview request anyway",
71+
);
72+
}
73+
74+
for (const candidate of candidates) {
75+
try {
76+
await octokit.rest.pulls.requestReviewers({
77+
owner,
78+
repo,
79+
pull_number: prNumber,
80+
team_reviewers: [candidate],
81+
});
82+
return { requestedTeam: candidate, alreadyRequested: false };
83+
} catch (err) {
84+
const status = getStatusCode(err);
85+
const canFallback = status === 422 && candidate !== candidates[candidates.length - 1];
86+
logger.warn(
87+
{
88+
err,
89+
owner,
90+
repo,
91+
prNumber,
92+
configuredTeam,
93+
candidate,
94+
status,
95+
fallbackRemaining: canFallback,
96+
},
97+
"Failed to request rereview team candidate",
98+
);
99+
if (!canFallback) break;
100+
}
101+
}
102+
103+
return { alreadyRequested: false };
104+
}

src/handlers/review.ts

Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
buildReviewOutputKey,
1616
ensureReviewOutputNotPublished,
1717
} from "./review-idempotency.ts";
18+
import { requestRereviewTeamBestEffort } from "./rereview-team.ts";
1819
import { $ } from "bun";
1920
import { fetchAndCheckoutPullRequestHeadRef } from "../jobs/workspace.ts";
2021

@@ -61,50 +62,6 @@ export function createReviewHandler(deps: {
6162

6263
const rereviewTeamSlugs = new Set(["ai-review", "aireview"]);
6364

64-
async function ensureUiRereviewTeamRequested(options: {
65-
octokit: Awaited<ReturnType<GitHubApp["getInstallationOctokit"]>>;
66-
owner: string;
67-
repo: string;
68-
prNumber: number;
69-
team: string;
70-
logger: Logger;
71-
}): Promise<void> {
72-
const { octokit, owner, repo, prNumber, team } = options;
73-
const normalized = team.trim().toLowerCase();
74-
if (normalized.length === 0) return;
75-
76-
try {
77-
const requested = await octokit.rest.pulls.listRequestedReviewers({
78-
owner,
79-
repo,
80-
pull_number: prNumber,
81-
});
82-
const already =
83-
(requested.data.teams ?? []).some((t) => (t.slug ?? "").trim().toLowerCase() === normalized) ||
84-
(requested.data.teams ?? []).some((t) => (t.name ?? "").trim().toLowerCase() === normalized);
85-
if (already) return;
86-
} catch (err) {
87-
logger.warn(
88-
{ err, owner, repo, prNumber, team },
89-
"Failed to list requested reviewers; attempting to request ui rereview team anyway",
90-
);
91-
}
92-
93-
try {
94-
await octokit.rest.pulls.requestReviewers({
95-
owner,
96-
repo,
97-
pull_number: prNumber,
98-
team_reviewers: [team],
99-
});
100-
} catch (err) {
101-
logger.warn(
102-
{ err, owner, repo, prNumber, team },
103-
"Failed to request ui rereview team (best-effort)",
104-
);
105-
}
106-
}
107-
10865
async function handleReview(event: WebhookEvent): Promise<void> {
10966
const payload = event.payload as unknown as
11067
| PullRequestOpenedEvent
@@ -333,12 +290,12 @@ export function createReviewHandler(deps: {
333290
(action === "opened" || action === "ready_for_review")
334291
) {
335292
const octokit = await githubApp.getInstallationOctokit(event.installationId);
336-
await ensureUiRereviewTeamRequested({
293+
await requestRereviewTeamBestEffort({
337294
octokit,
338295
owner: apiOwner,
339296
repo: apiRepo,
340297
prNumber: pr.number,
341-
team: config.review.uiRereviewTeam,
298+
configuredTeam: config.review.uiRereviewTeam,
342299
logger,
343300
});
344301
}

0 commit comments

Comments
 (0)