fix: add rate-limit retry to PR creation and fallback issue paths#31244
fix: add rate-limit retry to PR creation and fallback issue paths#31244
Conversation
Wrap githubClient.rest.pulls.create() and createFallbackIssue() in create_pull_request.cjs with RATE_LIMIT_RETRY_CONFIG (5 retries, exponential backoff up to 4 min, X-RateLimit-Reset honored). Wrap manifest-protection githubClient.rest.issues.create() in push_to_pull_request_branch.cjs with the same retry config. Add 3 vitest tests covering: - PR creation retries on rate-limit and succeeds - PR creation falls back to issue after all rate-limit retries exhausted - Fallback issue creation retries on rate-limit and succeeds Agent-Logs-Url: https://github.com/github/gh-aw/sessions/03801e76-0170-4920-a610-9833e75261f3 Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/03801e76-0170-4920-a610-9833e75261f3 Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds rate-limit-aware retry/backoff to GitHub PR/issue creation paths so safe-outputs work isn’t silently dropped when the GitHub App installation token quota is exhausted.
Changes:
- Wrap
pulls.create()increate_pull_request.cjswithwithRetry(RATE_LIMIT_RETRY_CONFIG)to handle rate-limit/transient failures with exponential backoff. - Wrap fallback issue creation paths with the same retry config (including manifest-protection fallback issue creation in
push_to_pull_request_branch.cjs). - Add Vitest coverage for PR creation retry success, retry exhaustion with fallback-to-issue, and retrying fallback issue creation.
Show a summary per file
| File | Description |
|---|---|
| actions/setup/js/create_pull_request.cjs | Adds rate-limit retry to PR creation and wraps fallback issue creation with the shared retry config. |
| actions/setup/js/push_to_pull_request_branch.cjs | Adds rate-limit retry to manifest-protection fallback issue creation. |
| actions/setup/js/create_pull_request.test.cjs | Adds tests covering PR create retry behavior and fallback issue retry behavior. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 3/3 changed files
- Comments generated: 2
| * Creates a fallback GitHub issue, retrying on rate-limit errors (with exponential back-off) | ||
| * and retrying without assignees if the API rejects them. | ||
| * This ensures fallback issue creation remains reliable even if an assignee username | ||
| * is invalid or the repository does not have that collaborator. | ||
| * is invalid, the repository does not have that collaborator, or the installation token | ||
| * quota is temporarily exhausted. |
| if (isAssigneeError && assignees && assignees.length > 0) { | ||
| core.warning(`Fallback issue creation failed due to assignee error, retrying without assignees: ${getErrorMessage(error)}`); | ||
| const { assignees: _removed, ...payloadWithoutAssignees } = payload; | ||
| return await githubClient.rest.issues.create(payloadWithoutAssignees); | ||
| } |
There was a problem hiding this comment.
Skills-Based Review 🧠
Applied /diagnose and /tdd — this is a targeted bug fix for silent quota-exhaustion failures, with accompanying regression tests.
Key Themes
- Test → implementation coupling: The
toHaveBeenCalledTimes(6)assertion in the fallback test hardcodes a magic number derived fromRATE_LIMIT_RETRY_CONFIG.maxRetries. Importing the constant removes the coupling and keeps the test self-consistent when the config is tuned. - Retry callback scope: In
createFallbackIssue, the assignee-fallback logic lives inside thewithRetrycallback. If the assignee-less call hits a rate-limit,withRetryrestarts from the top — re-attempting with invalid assignees before recovering again. Functionally correct, but wastes one call per retry under quota pressure. - Missing test coverage in
push_to_pull_request_branch.cjs: The manifest-protection issue path now has retry wiring, but no test exercises that path under a rate-limit scenario.
Positive Highlights
- ✅ Clean, focused change — only the two write paths that were previously unprotected are touched
- ✅ Descriptive
contextstrings passed towithRetrymake warnings easy to trace in logs - ✅ Three well-structured test scenarios with
vi.useFakeTimers()correctly handling async retry delays - ✅ Existing assignee-retry behaviour is preserved correctly inside the new
withRetrywrapper
Verdict
Observations only — no blocking issues. The fix addresses the real root cause and the test suite gives confidence in the happy and sad paths.
Note
🔒 Integrity filter blocked 1 item
The following item was blocked because it doesn't meet the GitHub integrity level.
- #31244
pull_request_read: has lower integrity than agent requires. The agent cannot read data with integrity below "approved".
To allow these resources, lower min-integrity in your GitHub frontmatter:
tools:
github:
min-integrity: approved # merged | approved | unapproved | none🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · ● 5.9M
| expect(result.fallback_used).toBe(true); | ||
| expect(result.issue_number).toBe(99); | ||
| // 1 initial + 5 retries = 6 total PR creation attempts (RATE_LIMIT_RETRY_CONFIG.maxRetries = 5) | ||
| expect(global.github.rest.pulls.create).toHaveBeenCalledTimes(6); |
There was a problem hiding this comment.
[/tdd] This assertion hardcodes 6 (1 initial + 5 retries), coupling the test to RATE_LIMIT_RETRY_CONFIG.maxRetries. If the retry config changes, this test fails with a cryptic number mismatch.
Consider importing the constant:
const { RATE_LIMIT_RETRY_CONFIG } = require('./error_recovery.cjs');
// ...
expect(global.github.rest.pulls.create).toHaveBeenCalledTimes(RATE_LIMIT_RETRY_CONFIG.maxRetries + 1);This makes the test self-documenting and resilient to config tuning.
| if (isAssigneeError && assignees && assignees.length > 0) { | ||
| core.warning(`Fallback issue creation failed due to assignee error, retrying without assignees: ${getErrorMessage(error)}`); | ||
| const { assignees: _removed, ...payloadWithoutAssignees } = payload; | ||
| return await githubClient.rest.issues.create(payloadWithoutAssignees); |
There was a problem hiding this comment.
[/diagnose] If the assignee-less issues.create call on this line throws a rate-limit error, withRetry will restart the entire callback — including the first issues.create(payload) call with assignees. The retry cycle will hit the 422 assignee error again, recover inline, then make a fresh attempt without assignees. This is correct but wastes an extra API call on every rate-limit retry.
If reducing call count under quota pressure matters, sanitize assignees before entering withRetry so all retries go directly to the clean payload:
const sanitizedAssignees = sanitizeFallbackAssignees(assignees);
const payload = { ..., ...(sanitizedAssignees.length > 0 && { assignees: sanitizedAssignees }) };
return withRetry(
() => githubClient.rest.issues.create(payload),
RATE_LIMIT_RETRY_CONFIG,
`create fallback issue in ${repoParts.owner}/${repoParts.repo}`
);Not blocking — current behaviour is functionally correct.
| body: issueBody, | ||
| labels: ["agentic-workflows"], | ||
| }); | ||
| const { data: issue } = await withRetry( |
There was a problem hiding this comment.
[/tdd] The manifest-protection issue creation path now uses withRetry, but there are no new tests covering this code path's rate-limit behaviour. The only rate-limit tests are in create_pull_request.test.cjs.
A test for push_to_pull_request_branch.cjs that simulates a rate-limit error on the manifest-protection issues.create call would confirm this retry path works end-to-end and protect against regressions if the retry wiring is ever accidentally removed.
🧪 Test Quality Sentinel ReportTest Quality Score: 80/100✅ Excellent — all new tests verify behavioral contracts
Test Classification Details
Flagged Tests — Requires ReviewNo tests are flagged for behavioral quality issues. All three tests assert on observable outputs (success flag, PR/issue number, fallback indicator) and exercise genuine error paths (rate-limit errors via mock chaining). i️ Test Inflation Note175 new test lines were added against 51 new production lines (≈3.4:1 ratio, threshold 2:1). This is expected here: the tests require significant boilerplate ( i️ Call-Count AssertionsAll three tests use Language SupportTests analyzed:
Verdict
📖 Understanding Test ClassificationsDesign Tests (High Value) verify what the system does:
Implementation Tests (Low Value) verify how the system does it:
Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators. References: §25607131257
|
|
@copilot review comments |
… JSDoc Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Both review comments have been addressed in commit
|
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
|
@copilot insert a note in the issue that the assignees were removed when removing assignee |
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Done in the latest commit. When a 422 assignee error triggers the retry-without-assignees path, the issue body now gets a A new test covers this path and verifies the note appears in the second |
When the GitHub App installation token quota hits 0/15,000,
safe_outputswas silently dropping agent work:pulls.create()had no retry logic, and when it failed, the fallbackissues.create()also failed immediately — no backoff, no recovery.Changes
create_pull_request.cjsgithubClient.rest.pulls.create()withwithRetry(RATE_LIMIT_RETRY_CONFIG)— 5 retries, exponential backoff (30s→60s→120s→240s→240s), honorsX-RateLimit-ResetheadercreateFallbackIssue()with the same retry config internally (covering rate-limit and other transient errors), so all call sites (push-failed, permission-denied, general PR failure) are covered without repetition[!NOTE]callout is appended to the fallback issue body so users know which assignees could not be setpush_to_pull_request_branch.cjsissues.create()withwithRetry(RATE_LIMIT_RETRY_CONFIG)create_pull_request.test.cjsRATE_LIMIT_RETRY_CONFIGwas already used bycreate_issue.cjs; this brings the PR creation path to parity.