Skip to content

fix(gh-design): skip self-review requests and tolerate 422 errors#202

Open
snomiao wants to merge 13 commits intomainfrom
fix/gh-design-self-review
Open

fix(gh-design): skip self-review requests and tolerate 422 errors#202
snomiao wants to merge 13 commits intomainfrom
fix/gh-design-self-review

Conversation

@snomiao
Copy link
Copy Markdown
Member

@snomiao snomiao commented Apr 22, 2026

Summary

The 5-minute Combined GitHub Tasks workflow has been failing every run. Root cause:

GitHub Design Task: 21128ms - Review cannot be requested from pull request author.
1 task(s) failed. Exiting with error code 1.

REQUEST_REVIEWERS is hardcoded to ["PabloWiedemann"] in app/tasks/gh-design/gh-design.ts. When Pablo opens a PR himself, the task calls gh.pulls.requestReviewers with him as the reviewer, GitHub responds 422 Review cannot be requested from pull request author, and run-gh-tasks.ts then exits non-zero — turning one avoidable PR-level error into a full workflow failure on every 5-minute tick.

Fix

  • Skip self-review: filter the PR author (task.user) out of the reviewer list before calling the API (case-insensitive comparison).
  • Tolerate 422: wrap the call in a try/catch that swallows only status === 422 (non-collaborators, etc.), rethrowing anything else. 422s are retried on subsequent runs since the condition may be transient.
  • Persist only on success: reviewer list is saved to DB only after a successful API call, keeping DB state accurate.
  • No redundant retries: the guard condition uses the filtered newReviewers list, so when all eligible reviewers are already requested, the block is skipped entirely.
  • Extracted filterReviewers() into a side-effect-free module with 8 unit tests covering author filtering, case insensitivity, and edge cases.

Test plan

  • CI run_comfy_pr and run_combined_github_tasks pass
  • filterReviewers.spec.ts — 8 tests passing
  • Next scheduled Combined GitHub Tasks run finishes green.
  • PRs by non-authors still get reviewer requests as before.
  • PRs authored by a REQUEST_REVIEWERS member skip the call silently.

The 5-minute combined GitHub tasks workflow has been failing every run
because GitHub Design Task tried to request review from PabloWiedemann
on PRs he authored himself. GitHub rejects this with:

  422 Review cannot be requested from pull request author.

Since run-gh-tasks.ts exits non-zero on any sub-task failure, this one
error takes the whole scheduled job down on every run.

Fix:
- Filter the PR author out of the reviewers list before calling
  requestReviewers, so self-authored PRs don't trigger the 422.
- Catch 422 responses defensively so an unexpected rejection (e.g. a
  reviewer who has left the org, a collaborator permission change) does
  not fail the task.
- Persist the reviewers list even when no API call is needed, so the
  task doesn't re-attempt the same request on every 5-minute tick.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 22, 2026 13:06
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
comfy-pr Ready Ready Preview, Comment Apr 30, 2026 11:15am

Request Review

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes the scheduled “GitHub Design Task” workflow failure by preventing invalid reviewer-request API calls (self-review) and handling expected 422 responses without failing the whole run.

Changes:

  • Filters the PR author out of the reviewer request list to avoid self-review requests.
  • Wraps gh.pulls.requestReviewers with 422-tolerant error handling.
  • Persists the attempted reviewer set to avoid retrying every scheduled run.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/tasks/gh-design/gh-design.ts Outdated
Comment thread app/tasks/gh-design/gh-design.ts Outdated
Comment on lines +267 to +281
if (newReviewers.length > 0) {
try {
await gh.pulls.requestReviewers({
owner,
repo,
pull_number: issue_number,
reviewers: newReviewers,
});
} catch (err: any) {
// GitHub returns 422 when the requested reviewer is the PR author
// or otherwise cannot be added. Record the attempt to avoid
// retrying on every 5-minute schedule run.
if (err?.status !== 422) throw err;
tlog(`Reviewer request rejected (422): ${err.message}`);
}
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

This change adds new branching behavior (self-review filtering, skipping API call when empty, swallowing only 422s) but there’s no automated coverage around the reviewer-request flow. Consider adding a small bun:test spec that exercises self-authored PRs (no API call, reviewers persisted) and a simulated 422 from gh.pulls.requestReviewers (task continues and persists the attempt) to prevent regressions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in f97f018 — extracted filterReviewers() as a pure function and added filterReviewers.spec.ts with 6 tests covering: self-authored PRs (empty list), already-requested reviewers, mixed scenarios, and undefined alreadyRequested.

Comment thread app/tasks/gh-design/gh-design.ts Outdated
Comment on lines +261 to +265
const requestReviewers = REQUEST_REVIEWERS;
const newReviewers = requestReviewers.filter((e) => !task.reviewers?.includes(e));
tlog(`Requesting reviewers: ${newReviewers.join(", ")}`);
const newReviewers = requestReviewers.filter(
(e) => !task.reviewers?.includes(e) && e !== task.user,
);
tlog(`Requesting reviewers: ${newReviewers.join(", ") || "(none)"}`);
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

task.reviewers is documented/used as the set of requested reviewers, but this code persists requestReviewers = REQUEST_REVIEWERS (unfiltered) even when the PR author was filtered out. That will make the dashboard show the author as a requested reviewer even though the API call was skipped. Consider defining requestReviewers as the filtered base list (e.g., excluding task.user) and updating the guard condition to use that same list, so you can persist accurate reviewer data while still avoiding retries when the filtered list is empty.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f97f018 — requestReviewers now filters out task.user before use, so persisted data accurately reflects only the reviewers who were actually eligible to be requested.

Comment thread app/tasks/gh-design/gh-design.ts Outdated
Comment on lines +275 to +280
} catch (err: any) {
// GitHub returns 422 when the requested reviewer is the PR author
// or otherwise cannot be added. Record the attempt to avoid
// retrying on every 5-minute schedule run.
if (err?.status !== 422) throw err;
tlog(`Reviewer request rejected (422): ${err.message}`);
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

Catching err: any makes it easy to accidentally swallow/handle unexpected error shapes. Prefer unknown and narrow to Octokit’s request error type (or a small structural check) before reading status/message, so the 422 handling is type-safe and resilient to non-Octokit exceptions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f97f018 — changed catch (err: any) to catch (err: unknown) with structural narrowing via (err as { status?: number })?.status.

@snomiao snomiao enabled auto-merge (squash) April 24, 2026 00:17
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
snomiao and others added 5 commits April 29, 2026 04:36
The 5-minute combined GitHub tasks workflow has been failing every run
because GitHub Design Task tried to request review from PabloWiedemann
on PRs he authored himself. GitHub rejects this with:

  422 Review cannot be requested from pull request author.

Since run-gh-tasks.ts exits non-zero on any sub-task failure, this one
error takes the whole scheduled job down on every run.

Fix:
- Filter the PR author out of the reviewers list before calling
  requestReviewers, so self-authored PRs don't trigger the 422.
- Catch 422 responses defensively so an unexpected rejection (e.g. a
  reviewer who has left the org, a collaborator permission change) does
  not fail the task.
- Persist the reviewers list even when no API call is needed, so the
  task doesn't re-attempt the same request on every 5-minute tick.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…from persisted reviewers

- Filter task.user from requestReviewers (not just newReviewers) so
  persisted data accurately reflects who was actually requested
- Use catch (err: unknown) with structural narrowing instead of any
  for type-safe 422 handling

Amp-Thread-ID: https://ampcode.com/threads/T-019ddde1-db08-71ca-809d-fd7e51b68842
Co-authored-by: Amp <amp@ampcode.com>
Extract reviewer filtering into a testable pure function and add
tests covering: self-authored PRs, already-requested reviewers,
empty reviewer lists, and undefined alreadyRequested.

Amp-Thread-ID: https://ampcode.com/threads/T-019ddde1-db08-71ca-809d-fd7e51b68842
Co-authored-by: Amp <amp@ampcode.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1 to +6
import { describe, expect, it } from "bun:test";
import { filterReviewers } from "./gh-design";

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

Copy link

Copilot AI Apr 30, 2026

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
Member 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.

Comment thread app/tasks/gh-design/gh-design.ts Outdated
Comment on lines +271 to +279
@@ -258,16 +272,31 @@ export async function runGithubDesignTask() {
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(", ")}`);
const { requestReviewers, newReviewers } = filterReviewers(
REQUEST_REVIEWERS,
task.user,
task.reviewers,
);
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

The guard condition uses REQUEST_REVIEWERS.some((e) => !task.reviewers?.includes(e)), but task.reviewers is persisted as the filtered requestReviewers (excluding task.user). If the PR author is in REQUEST_REVIEWERS, task.reviewers will never include them, so this condition remains true forever and the task will re-enter this block (and re-save) on every 5‑minute run. Update the condition to compare against the same filtered/eligible reviewer set you persist (e.g., compute eligible reviewers excluding task.user first, and use that list both for the some(...) check and for saving).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in cee9996 — removed the REQUEST_REVIEWERS.some(...) guard and replaced it with newReviewers.length > 0 check after computing the filtered list. Now when the PR author is in REQUEST_REVIEWERS and has already been filtered out, the block won't re-enter and re-save on every tick.

…le, fix guard condition

- Move filterReviewers() into its own module to avoid DB side effects
  in tests (Copilot review comment)
- Fix guard condition: use filtered newReviewers.length instead of
  comparing against unfiltered REQUEST_REVIEWERS, which caused the
  task to re-enter and re-save on every 5-minute tick when the author
  was in the reviewer list (Copilot review comment)

Amp-Thread-ID: https://ampcode.com/threads/T-019ddde1-db08-71ca-809d-fd7e51b68842
Co-authored-by: Amp <amp@ampcode.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/tasks/gh-design/gh-design.ts Outdated
Comment on lines +279 to +283
const status = (err as { status?: number })?.status;
if (status !== 422) throw err;
tlog(`Reviewer request rejected (422): ${err}`);
}
task = await saveGithubDesignTask(url, { reviewers: requestReviewers });
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

On a 422, this code persists reviewers: requestReviewers even though GitHub rejected the request. Since reviewers is later displayed in the dashboard as “requested reviewers”, this will show reviewers as requested when they weren’t. Consider persisting an explicit flag/error (e.g., reviewerRequestRejected: true / reviewerRequestError) or storing attempted reviewers in a separate field, so the DB/UI state remains accurate while still preventing retries.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — reviewers are now only persisted to DB on successful API call. On 422 rejection, the task will retry on the next run.

Comment thread app/tasks/gh-design/filterReviewers.ts Outdated
Comment on lines +5 to +11
export function filterReviewers(
allReviewers: string[],
prAuthor: string,
alreadyRequested?: string[],
): { requestReviewers: string[]; newReviewers: string[] } {
const requestReviewers = allReviewers.filter((e) => e !== prAuthor);
const newReviewers = requestReviewers.filter((e) => !alreadyRequested?.includes(e));
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

filterReviewers compares GitHub usernames case-sensitively (e !== prAuthor / includes). Since GitHub logins are effectively case-insensitive and reviewer lists can be edited via the task config UI, normalizing to a consistent case for comparisons (while returning original casing for API/display) would make the behavior more robust.

Suggested change
export function filterReviewers(
allReviewers: string[],
prAuthor: string,
alreadyRequested?: string[],
): { requestReviewers: string[]; newReviewers: string[] } {
const requestReviewers = allReviewers.filter((e) => e !== prAuthor);
const newReviewers = requestReviewers.filter((e) => !alreadyRequested?.includes(e));
function normalizeGitHubLogin(login: string): string {
return login.toLowerCase();
}
export function filterReviewers(
allReviewers: string[],
prAuthor: string,
alreadyRequested?: string[],
): { requestReviewers: string[]; newReviewers: string[] } {
const normalizedPrAuthor = normalizeGitHubLogin(prAuthor);
const normalizedAlreadyRequested = new Set(
alreadyRequested?.map((reviewer) => normalizeGitHubLogin(reviewer)) ?? [],
);
const requestReviewers = allReviewers.filter(
(e) => normalizeGitHubLogin(e) !== normalizedPrAuthor,
);
const newReviewers = requestReviewers.filter(
(e) => !normalizedAlreadyRequested.has(normalizeGitHubLogin(e)),
);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in e43396f — filterReviewers() now normalizes usernames to lowercase for all comparisons while preserving original casing in the returned arrays. Added 2 tests for case-insensitive matching.

snomiao and others added 2 commits April 30, 2026 10:35
On 422 rejection, don't save the reviewer list to the database since
the reviewers were never actually requested. This keeps DB state
accurate and allows retry on subsequent runs.

Amp-Thread-ID: https://ampcode.com/threads/T-019ddde1-db08-71ca-809d-fd7e51b68842
Co-authored-by: Amp <amp@ampcode.com>
GitHub logins are case-insensitive, so normalize comparisons in
filterReviewers() to prevent edge cases with mixed-case usernames.
Added 2 tests for case-insensitive matching.

Amp-Thread-ID: https://ampcode.com/threads/T-019ddde1-db08-71ca-809d-fd7e51b68842
Co-authored-by: Amp <amp@ampcode.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +277 to +284
// GitHub may return 422 when a requested reviewer cannot be added,
// such as when they are not a collaborator, cannot be requested,
// or have already been requested. Record the attempt to avoid
// retrying on every 5-minute schedule run.
const status = (err as { status?: number })?.status;
if (status !== 422) throw err;
tlog(`Reviewer request rejected (422): ${err}`);
}
Copy link

Copilot AI Apr 30, 2026

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
Member 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.

Update comment to accurately state that 422 rejections are retried
on subsequent runs (not suppressed), since the reviewer may become
eligible later.

Amp-Thread-ID: https://ampcode.com/threads/T-019ddde1-db08-71ca-809d-fd7e51b68842
Co-authored-by: Amp <amp@ampcode.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/tasks/gh-design/filterReviewers.ts Outdated
Comment on lines +2 to +3
* Filter the reviewer list for a PR: excludes the PR author and
* anyone who has already been requested.
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

The module docstring says the filter excludes both the PR author and “anyone who has already been requested”, but requestReviewers only filters out the author (already-requested reviewers are only excluded from newReviewers). This comment is currently misleading—update it to describe the two return lists accurately (e.g., requestReviewers = eligible reviewers excluding author; newReviewers = eligible reviewers not yet requested).

Suggested change
* Filter the reviewer list for a PR: excludes the PR author and
* anyone who has already been requested.
* Filter the reviewer list for a PR.
* `requestReviewers` contains eligible reviewers excluding the PR author.
* `newReviewers` contains eligible reviewers who have not already been requested.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in add499b — updated the docstring to accurately describe the two return values: requestReviewers (minus author) and newReviewers (minus author and already-requested).

Comment on lines +258 to +287
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}`);
}
if (reviewersRequested) {
task = await saveGithubDesignTask(url, { reviewers: requestReviewers });
}
Copy link

Copilot AI Apr 30, 2026

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
Member 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.

Clarify that requestReviewers excludes only the author while
newReviewers additionally excludes already-requested reviewers.

Amp-Thread-ID: https://ampcode.com/threads/T-019ddde1-db08-71ca-809d-fd7e51b68842
Co-authored-by: Amp <amp@ampcode.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants