fix(gh-design): skip self-review requests and tolerate 422 errors#202
fix(gh-design): skip self-review requests and tolerate 422 errors#202
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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.requestReviewerswith 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.
| 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}`); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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)"}`); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| } 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}`); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in f97f018 — changed catch (err: any) to catch (err: unknown) with structural narrowing via (err as { status?: number })?.status.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…Org/Comfy-PR into fix/gh-design-self-review
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>
4cf8723 to
f97f018
Compare
There was a problem hiding this comment.
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.
| import { describe, expect, it } from "bun:test"; | ||
| import { filterReviewers } from "./gh-design"; | ||
|
|
||
| describe("filterReviewers", () => { | ||
| const REVIEWERS = ["PabloWiedemann", "AliceDev"]; | ||
|
|
There was a problem hiding this comment.
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.
| 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")); | |
| }); |
There was a problem hiding this comment.
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.
| @@ -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, | |||
| ); | |||
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
| const status = (err as { status?: number })?.status; | ||
| if (status !== 422) throw err; | ||
| tlog(`Reviewer request rejected (422): ${err}`); | ||
| } | ||
| task = await saveGithubDesignTask(url, { reviewers: requestReviewers }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed — reviewers are now only persisted to DB on successful API call. On 422 rejection, the task will retry on the next run.
| 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)); |
There was a problem hiding this comment.
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.
| 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)), | |
| ); |
There was a problem hiding this comment.
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.
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>
There was a problem hiding this comment.
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.
| // 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}`); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
| * Filter the reviewer list for a PR: excludes the PR author and | ||
| * anyone who has already been requested. |
There was a problem hiding this comment.
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).
| * 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. |
There was a problem hiding this comment.
Fixed in add499b — updated the docstring to accurately describe the two return values: requestReviewers (minus author) and newReviewers (minus author and already-requested).
| 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 }); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
Summary
The 5-minute
Combined GitHub Tasksworkflow has been failing every run. Root cause:REQUEST_REVIEWERSis hardcoded to["PabloWiedemann"]inapp/tasks/gh-design/gh-design.ts. When Pablo opens a PR himself, the task callsgh.pulls.requestReviewerswith him as the reviewer, GitHub responds 422 Review cannot be requested from pull request author, andrun-gh-tasks.tsthen exits non-zero — turning one avoidable PR-level error into a full workflow failure on every 5-minute tick.Fix
task.user) out of the reviewer list before calling the API (case-insensitive comparison).status === 422(non-collaborators, etc.), rethrowing anything else. 422s are retried on subsequent runs since the condition may be transient.newReviewerslist, so when all eligible reviewers are already requested, the block is skipped entirely.filterReviewers()into a side-effect-free module with 8 unit tests covering author filtering, case insensitivity, and edge cases.Test plan
run_comfy_prandrun_combined_github_taskspassfilterReviewers.spec.ts— 8 tests passingCombined GitHub Tasksrun finishes green.REQUEST_REVIEWERSmember skip the call silently.