@@ -24,7 +24,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
2424const { isStagedMode } = require ( "./safe_output_helpers.cjs" ) ;
2525const { generateWorkflowCallIdMarker, matchesWorkflowId } = require ( "./generate_footer.cjs" ) ;
2626const { 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
2929const SUPERSEDE_REVIEW_MESSAGE = "Superseded by updated review from same workflow." ;
3030const MAX_SUPERSEDE_REVIEW_PAGES = 10 ;
@@ -35,6 +35,18 @@ const FALLBACK_EMPTY_COMMENT_BODY = "_(empty comment body)_";
3535const FALLBACK_TRUNCATION_SUFFIX = "\n\n_(Fallback review body truncated to fit GitHub length limits.)_" ;
3636const FALLBACK_OMISSION_NOTE = "_(Unanchored comment details omitted to fit GitHub length limits.)_" ;
3737const 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.
4052const 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 {
0 commit comments