Skip to content

Commit b4be417

Browse files
authored
fix: treat locked-PR 422 as soft skip with retry in safe_outputs (#41155)
1 parent 6fb7896 commit b4be417

5 files changed

Lines changed: 266 additions & 51 deletions

actions/setup/js/pr_review_buffer.cjs

Lines changed: 71 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
2424
const { isStagedMode } = require("./safe_output_helpers.cjs");
2525
const { generateWorkflowCallIdMarker, matchesWorkflowId } = require("./generate_footer.cjs");
2626
const { attachExecutionState, fetchPullRequestReviewState } = require("./safe_output_execution_metadata.cjs");
27-
const { withRetry, RATE_LIMIT_RETRY_CONFIG, isTransientError } = require("./error_recovery.cjs");
27+
const { withRetry, RATE_LIMIT_RETRY_CONFIG, isTransientError, sleep } = require("./error_recovery.cjs");
2828

2929
const SUPERSEDE_REVIEW_MESSAGE = "Superseded by updated review from same workflow.";
3030
const MAX_SUPERSEDE_REVIEW_PAGES = 10;
@@ -35,6 +35,18 @@ const FALLBACK_EMPTY_COMMENT_BODY = "_(empty comment body)_";
3535
const FALLBACK_TRUNCATION_SUFFIX = "\n\n_(Fallback review body truncated to fit GitHub length limits.)_";
3636
const FALLBACK_OMISSION_NOTE = "_(Unanchored comment details omitted to fit GitHub length limits.)_";
3737
const ELLIPSIS = "…";
38+
// GitHub API message fragment returned when a PR is locked and review submission is rejected.
39+
// Must be lowercase — compared against errorMessage.toLowerCase() for case-insensitive matching.
40+
const LOCKED_PR_REVIEW_MESSAGE = "lock prevents review";
41+
42+
/** Returns true if the error message indicates a locked-PR 422 rejection. */
43+
const isLockedPrError = errorMessage => errorMessage.toLowerCase().includes(LOCKED_PR_REVIEW_MESSAGE);
44+
// Number of retries before treating a locked-PR 422 as a permanent soft skip.
45+
// A small number is used so the run does not stall when the PR is permanently locked.
46+
const LOCKED_PR_RETRY_COUNT = 3;
47+
// Delay between lock-retry attempts. Short enough to keep the run responsive
48+
// while still giving a transient lock a few seconds to clear.
49+
const LOCKED_PR_RETRY_DELAY_MS = 5000;
3850
// Keep review retries bounded so safe-outputs can recover from short installation-token
3951
// quota stalls without spending most of the workflow timeout waiting for a reset.
4052
const REVIEW_RATE_LIMIT_RETRY_CONFIG = {
@@ -549,13 +561,17 @@ function createReviewBuffer() {
549561
return beforeState ? fetchReviewStateBestEffort(repoParts, pullRequestNumber, "after") : null;
550562
}
551563

552-
try {
553-
const { data: review } = await createReviewWithRetry(requestParams);
554-
await maybeSupersedeOlderReviews(review.id);
555-
const afterState = await fetchAfterStateIfAvailable();
556-
557-
core.info(`Created PR review #${review.id}: ${review.html_url}`);
558-
564+
/**
565+
* Build the success result payload for a submitted PR review, wrapping it with
566+
* execution-state metadata. Extracted to avoid duplicating the shape across the
567+
* initial submit, own-PR-COMMENT retry, locked-PR retry, and body-only fallback paths.
568+
*
569+
* @param {{ id: number, html_url: string, state?: string }} review - Created review object
570+
* @param {string} resolvedEvent - The review event actually used (may differ from the requested event)
571+
* @param {number} commentCount - Number of inline comments included
572+
* @param {import("./safe_output_execution_metadata.cjs").ReviewState|null} afterState - Post-submit review state
573+
*/
574+
function buildReviewSuccessResult(review, resolvedEvent, commentCount, afterState) {
559575
return attachExecutionState(
560576
{
561577
success: true,
@@ -565,17 +581,27 @@ function createReviewBuffer() {
565581
review_url: review.html_url,
566582
pull_request_number: pullRequestNumber,
567583
repo: repo,
568-
event: event,
569-
comment_count: comments.length,
584+
event: resolvedEvent,
585+
comment_count: commentCount,
570586
metadata: {
571587
review_id: review.id,
572-
review_event: event,
588+
review_event: resolvedEvent,
573589
...(review.state ? { review_state: review.state } : {}),
574590
},
575591
},
576592
beforeState,
577593
afterState
578594
);
595+
}
596+
597+
try {
598+
const { data: review } = await createReviewWithRetry(requestParams);
599+
await maybeSupersedeOlderReviews(review.id);
600+
const afterState = await fetchAfterStateIfAvailable();
601+
602+
core.info(`Created PR review #${review.id}: ${review.html_url}`);
603+
604+
return buildReviewSuccessResult(review, event, comments.length, afterState);
579605
} catch (error) {
580606
const errorMessage = getErrorMessage(error);
581607

@@ -591,26 +617,7 @@ function createReviewBuffer() {
591617
await maybeSupersedeOlderReviews(review.id);
592618
const afterState = await fetchAfterStateIfAvailable();
593619
core.info(`Created PR review #${review.id}: ${review.html_url}`);
594-
return attachExecutionState(
595-
{
596-
success: true,
597-
url: review.html_url,
598-
number: pullRequestNumber,
599-
review_id: review.id,
600-
review_url: review.html_url,
601-
pull_request_number: pullRequestNumber,
602-
repo: repo,
603-
event: "COMMENT",
604-
comment_count: comments.length,
605-
metadata: {
606-
review_id: review.id,
607-
review_event: "COMMENT",
608-
...(review.state ? { review_state: review.state } : {}),
609-
},
610-
},
611-
beforeState,
612-
afterState
613-
);
620+
return buildReviewSuccessResult(review, "COMMENT", comments.length, afterState);
614621
} catch (retryError) {
615622
core.error(`Failed to submit PR review on retry: ${getErrorMessage(retryError)}`);
616623
return {
@@ -620,6 +627,38 @@ function createReviewBuffer() {
620627
}
621628
}
622629

630+
// When the PR is locked, retry a few times to detect if the lock is temporary,
631+
// then treat as a soft skip (success:true, skipped:true) so the run is not failed.
632+
// GitHub returns 422 with message "lock prevents review" for locked PRs.
633+
// We check the error message (which withRetry/enhanceError preserves in "Original error:")
634+
// rather than the status code, which may not survive error wrapping.
635+
if (isLockedPrError(errorMessage)) {
636+
core.warning(`PR #${pullRequestNumber} is locked (422 "${LOCKED_PR_REVIEW_MESSAGE}"). Retrying ${LOCKED_PR_RETRY_COUNT} time(s) to check if the lock is temporary...`);
637+
for (let attempt = 1; attempt <= LOCKED_PR_RETRY_COUNT; attempt++) {
638+
await sleep(LOCKED_PR_RETRY_DELAY_MS);
639+
try {
640+
const { data: review } = await createReviewWithRetry(requestParams);
641+
await maybeSupersedeOlderReviews(review.id);
642+
const afterState = await fetchAfterStateIfAvailable();
643+
core.info(`Created PR review #${review.id} after lock retry (attempt ${attempt}/${LOCKED_PR_RETRY_COUNT}): ${review.html_url}`);
644+
return buildReviewSuccessResult(review, event, comments.length, afterState);
645+
} catch (retryError) {
646+
const retryErrorMessage = getErrorMessage(retryError);
647+
if (isLockedPrError(retryErrorMessage)) {
648+
core.warning(`PR #${pullRequestNumber} is still locked (attempt ${attempt}/${LOCKED_PR_RETRY_COUNT})`);
649+
} else {
650+
// Different error on retry — surface as a regular failure
651+
core.error(`Failed to submit PR review on lock retry attempt ${attempt}: ${retryErrorMessage}`);
652+
return { success: false, error: retryErrorMessage };
653+
}
654+
}
655+
}
656+
// All retries exhausted — treat as a soft skip so the run stays green
657+
const skipMsg = `Review skipped — PR #${pullRequestNumber} is locked`;
658+
core.warning(skipMsg);
659+
return { success: true, skipped: true, reason: skipMsg, pr_locked: true };
660+
}
661+
623662
// When the API cannot resolve a line or path reference in an inline comment, retry as a
624663
// body-only review so that the overall review (and its footer body) is still submitted
625664
// successfully. Matches both "Line could not be resolved" and "Path could not be resolved".
@@ -633,26 +672,7 @@ function createReviewBuffer() {
633672
await maybeSupersedeOlderReviews(review.id);
634673
const afterState = await fetchAfterStateIfAvailable();
635674
core.info(`Created PR review #${review.id} (body-only fallback): ${review.html_url}`);
636-
return attachExecutionState(
637-
{
638-
success: true,
639-
url: review.html_url,
640-
number: pullRequestNumber,
641-
review_id: review.id,
642-
review_url: review.html_url,
643-
pull_request_number: pullRequestNumber,
644-
repo: repo,
645-
event: event,
646-
comment_count: 0,
647-
metadata: {
648-
review_id: review.id,
649-
review_event: event,
650-
...(review.state ? { review_state: review.state } : {}),
651-
},
652-
},
653-
beforeState,
654-
afterState
655-
);
675+
return buildReviewSuccessResult(review, event, 0, afterState);
656676
} catch (retryError) {
657677
core.error(`Failed to submit body-only PR review: ${getErrorMessage(retryError)}`);
658678
return {

actions/setup/js/pr_review_buffer.test.cjs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,110 @@ describe("pr_review_buffer (factory pattern)", () => {
855855
expect(mockGithub.rest.pulls.createReview).toHaveBeenCalledTimes(2);
856856
});
857857

858+
it("should skip (success:true, skipped:true) when PR is permanently locked after all retries", async () => {
859+
const setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation(handler => {
860+
if (typeof handler === "function") {
861+
handler();
862+
}
863+
return 0;
864+
});
865+
try {
866+
buffer.setReviewMetadata("Looks good", "COMMENT");
867+
buffer.setReviewContext({
868+
repo: "owner/repo",
869+
repoParts: { owner: "owner", repo: "repo" },
870+
pullRequestNumber: 42,
871+
pullRequest: { head: { sha: "abc123" } },
872+
});
873+
874+
const lockedError = Object.assign(new Error("lock prevents review"), { status: 422 });
875+
mockGithub.rest.pulls.createReview.mockRejectedValue(lockedError);
876+
877+
const result = await buffer.submitReview();
878+
879+
expect(result.success).toBe(true);
880+
expect(result.skipped).toBe(true);
881+
expect(result.pr_locked).toBe(true);
882+
expect(result.reason).toContain("locked");
883+
// 1 initial + 3 retries
884+
expect(mockGithub.rest.pulls.createReview).toHaveBeenCalledTimes(4);
885+
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("lock prevents review"));
886+
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Review skipped"));
887+
} finally {
888+
setTimeoutSpy.mockRestore();
889+
}
890+
});
891+
892+
it("should succeed when locked-PR retry eventually unlocks", async () => {
893+
const setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation(handler => {
894+
if (typeof handler === "function") {
895+
handler();
896+
}
897+
return 0;
898+
});
899+
try {
900+
buffer.setReviewMetadata("Looks good", "COMMENT");
901+
buffer.setReviewContext({
902+
repo: "owner/repo",
903+
repoParts: { owner: "owner", repo: "repo" },
904+
pullRequestNumber: 42,
905+
pullRequest: { head: { sha: "abc123" } },
906+
});
907+
908+
const lockedError = Object.assign(new Error("lock prevents review"), { status: 422 });
909+
mockGithub.rest.pulls.createReview
910+
.mockRejectedValueOnce(lockedError)
911+
.mockRejectedValueOnce(lockedError)
912+
.mockResolvedValueOnce({
913+
data: {
914+
id: 999,
915+
html_url: "https://github.com/owner/repo/pull/42#pullrequestreview-999",
916+
},
917+
});
918+
919+
const result = await buffer.submitReview();
920+
921+
expect(result.success).toBe(true);
922+
expect(result.skipped).toBeUndefined();
923+
expect(result.review_id).toBe(999);
924+
// 1 initial + 2 locked retries (third attempt succeeds)
925+
expect(mockGithub.rest.pulls.createReview).toHaveBeenCalledTimes(3);
926+
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Retrying 3 time(s)"));
927+
} finally {
928+
setTimeoutSpy.mockRestore();
929+
}
930+
});
931+
932+
it("should return failure when lock retry encounters a different error", async () => {
933+
const setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation(handler => {
934+
if (typeof handler === "function") {
935+
handler();
936+
}
937+
return 0;
938+
});
939+
try {
940+
buffer.setReviewMetadata("Looks good", "COMMENT");
941+
buffer.setReviewContext({
942+
repo: "owner/repo",
943+
repoParts: { owner: "owner", repo: "repo" },
944+
pullRequestNumber: 42,
945+
pullRequest: { head: { sha: "abc123" } },
946+
});
947+
948+
const lockedError = Object.assign(new Error("lock prevents review"), { status: 422 });
949+
const otherError = Object.assign(new Error("internal server error"), { status: 500 });
950+
mockGithub.rest.pulls.createReview.mockRejectedValueOnce(lockedError).mockRejectedValueOnce(otherError);
951+
952+
const result = await buffer.submitReview();
953+
954+
expect(result.success).toBe(false);
955+
expect(result.error).toContain("internal server error");
956+
expect(mockGithub.rest.pulls.createReview).toHaveBeenCalledTimes(2);
957+
} finally {
958+
setTimeoutSpy.mockRestore();
959+
}
960+
});
961+
858962
it("should dismiss older reviews matching workflow-call-id when supersede mode is enabled", async () => {
859963
const previousWorkflowId = process.env.GH_AW_WORKFLOW_ID;
860964
const previousCallerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID;

actions/setup/js/safe_output_execution_metadata.cjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ async function fetchIssueState(github, repoParts, issueNumber) {
195195
return extractIssueStateFromData(issue);
196196
}
197197

198+
/**
199+
* @typedef {{ requested_reviewers: string[], requested_team_reviewers: string[], reviews: Array<{id?: number, user?: string, state?: string, submitted_at?: string}> }} ReviewState
200+
*/
201+
198202
function extractReviewStateFromData(pullRequest, reviews) {
199203
return {
200204
requested_reviewers: normalizeRequestedReviewers(pullRequest?.requested_reviewers),

actions/setup/js/safe_output_handler_manager.cjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,28 @@ function rollbackReviewResults(results, errorMessage) {
532532
}
533533
}
534534

535+
/**
536+
* Mark buffered review results as skipped when the PR is locked and submission was
537+
* soft-skipped (success:true, skipped:true). Both submit_pull_request_review and
538+
* create_pull_request_review_comment handlers buffer during message processing, so
539+
* the skip must be back-propagated here so the Processing Summary reflects the actual
540+
* outcome (skipped) rather than a misleading success count.
541+
*
542+
* Note: uses `skipReason` (not `reason`) so that the step-summary generator does not
543+
* treat these entries as delegated-step skips and omit them from the output.
544+
*
545+
* @param {Array<{type: string, success: boolean, skipped?: boolean, skipReason?: string}>} results - Processing results to mutate
546+
* @param {string} skipReason - Human-readable reason for the skip
547+
*/
548+
function skipReviewResults(results, skipReason) {
549+
for (const r of results) {
550+
if ((r.type === "submit_pull_request_review" || r.type === "create_pull_request_review_comment") && r.success === true) {
551+
r.skipped = true;
552+
r.skipReason = skipReason;
553+
}
554+
}
555+
}
556+
535557
/**
536558
* Determine whether a processing result is a non-skipped, non-deferred, non-cancelled failure.
537559
*
@@ -1418,6 +1440,13 @@ async function main() {
14181440
if (reviewResult.success && !reviewResult.skipped) {
14191441
logCreatedItemFromResult(logCreatedItem, "submit_pull_request_review", reviewResult);
14201442
core.info(`✓ PR review submitted successfully: ${reviewResult.review_url}`);
1443+
} else if (reviewResult.success && reviewResult.skipped) {
1444+
const skipReason = reviewResult.reason || "PR review submission skipped";
1445+
core.warning(`⚠ ${skipReason}`);
1446+
if (reviewResult.pr_locked) {
1447+
core.setOutput("pr_locked", "true");
1448+
}
1449+
skipReviewResults(processingResult.results, skipReason);
14211450
} else if (!reviewResult.success) {
14221451
reviewFailureError = reviewResult.error || "PR review finalization failed";
14231452
core.error(`✗ Failed to submit PR review: ${reviewFailureError}`);
@@ -1629,6 +1658,7 @@ module.exports = {
16291658
processMessages,
16301659
buildCommentMemoryMessagesFromFiles,
16311660
rollbackReviewResults,
1661+
skipReviewResults,
16321662
logCreatedItemFromResult,
16331663
isFailedProcessingResult,
16341664
isReportOnlyFailureResult,

0 commit comments

Comments
 (0)