diff --git a/actions/setup/js/pr_review_buffer.cjs b/actions/setup/js/pr_review_buffer.cjs index 1417445f2ab..3a3e3cf2a51 100644 --- a/actions/setup/js/pr_review_buffer.cjs +++ b/actions/setup/js/pr_review_buffer.cjs @@ -620,10 +620,29 @@ function createReviewBuffer() { core.info(`Created PR review #${review.id}: ${review.html_url}`); return buildReviewSuccessResult(review, "COMMENT", comments.length, afterState); } catch (retryError) { - core.error(`Failed to submit PR review on retry: ${getErrorMessage(retryError)}`); + const retryErrorMsg = getErrorMessage(retryError); + // If the COMMENT retry still fails due to unresolvable line(s), fall back to body-only COMMENT. + if (retryErrorMsg.includes("Line could not be resolved") || retryErrorMsg.includes("Path could not be resolved")) { + core.warning(`COMMENT retry on own PR failed with unresolvable line(s): ${retryErrorMsg}. Falling back to body-only COMMENT.`); + try { + const ownPrBodyOnlyParams = { ...requestParams }; + delete ownPrBodyOnlyParams.comments; + ownPrBodyOnlyParams.event = "COMMENT"; + ownPrBodyOnlyParams.body = appendUnanchoredCommentsSection(typeof requestParams.body === "string" ? requestParams.body : "", comments); + const { data: review } = await createReviewWithRetry(ownPrBodyOnlyParams); + await maybeSupersedeOlderReviews(review.id); + const afterState = await fetchAfterStateIfAvailable(); + core.info(`Created PR review #${review.id} (own-PR body-only COMMENT): ${review.html_url}`); + return buildReviewSuccessResult(review, "COMMENT", 0, afterState); + } catch (bodyOnlyError) { + core.error(`Failed to submit body-only COMMENT review: ${getErrorMessage(bodyOnlyError)}`); + return { success: false, error: getErrorMessage(bodyOnlyError) }; + } + } + core.error(`Failed to submit PR review on retry: ${retryErrorMsg}`); return { success: false, - error: getErrorMessage(retryError), + error: retryErrorMsg, }; } } @@ -691,20 +710,36 @@ function createReviewBuffer() { } core.warning(`PR review submission failed due to unresolvable comment line(s): ${errorMessage}. Retrying as body-only review.`); + const bodyOnlyParams = { ...requestParams }; + delete bodyOnlyParams.comments; + bodyOnlyParams.body = appendUnanchoredCommentsSection(typeof requestParams.body === "string" ? requestParams.body : "", comments); try { - const bodyOnlyParams = { ...requestParams }; - delete bodyOnlyParams.comments; - bodyOnlyParams.body = appendUnanchoredCommentsSection(typeof requestParams.body === "string" ? requestParams.body : "", comments); const { data: review } = await createReviewWithRetry(bodyOnlyParams); await maybeSupersedeOlderReviews(review.id); const afterState = await fetchAfterStateIfAvailable(); core.info(`Created PR review #${review.id} (body-only fallback): ${review.html_url}`); return buildReviewSuccessResult(review, event, 0, afterState); } catch (retryError) { - core.error(`Failed to submit body-only PR review: ${getErrorMessage(retryError)}`); + const retryErrorMsg = getErrorMessage(retryError); + // If body-only also fails because it's a self-authored PR, retry as body-only COMMENT. + if (bodyOnlyParams.event !== "COMMENT" && ownPrMessages.some(msg => retryErrorMsg.includes(msg))) { + core.warning(`Body-only ${bodyOnlyParams.event} review rejected on own PR. Retrying as body-only COMMENT.`); + try { + bodyOnlyParams.event = "COMMENT"; + const { data: review } = await createReviewWithRetry(bodyOnlyParams); + await maybeSupersedeOlderReviews(review.id); + const afterState = await fetchAfterStateIfAvailable(); + core.info(`Created PR review #${review.id} (body-only COMMENT fallback): ${review.html_url}`); + return buildReviewSuccessResult(review, "COMMENT", 0, afterState); + } catch (ownPrRetryError) { + core.error(`Failed to submit body-only COMMENT review: ${getErrorMessage(ownPrRetryError)}`); + return { success: false, error: getErrorMessage(ownPrRetryError) }; + } + } + core.error(`Failed to submit body-only PR review: ${retryErrorMsg}`); return { success: false, - error: getErrorMessage(retryError), + error: retryErrorMsg, }; } } diff --git a/actions/setup/js/pr_review_buffer.test.cjs b/actions/setup/js/pr_review_buffer.test.cjs index 8cf7c8a2126..31079debc65 100644 --- a/actions/setup/js/pr_review_buffer.test.cjs +++ b/actions/setup/js/pr_review_buffer.test.cjs @@ -933,6 +933,41 @@ describe("pr_review_buffer (factory pattern)", () => { expect(mockGithub.rest.pulls.createReview).toHaveBeenCalledTimes(2); }); + it("should fall back to body-only COMMENT when own-PR COMMENT retry also fails with line-resolution error", async () => { + buffer.addComment({ path: "file.go", line: 10, body: "Inline comment" }); + buffer.setReviewMetadata("Fix this", "REQUEST_CHANGES"); + buffer.setReviewContext({ + repo: "owner/repo", + repoParts: { owner: "owner", repo: "repo" }, + pullRequestNumber: 42, + pullRequest: { head: { sha: "abc123" }, user: { login: "bot-user" } }, + }); + + mockGithub.rest.pulls.createReview + // Initial attempt — own-PR 422 + .mockRejectedValueOnce(new Error("Can not request changes on your own pull request")) + // COMMENT retry with inline comments — line unresolvable + .mockRejectedValueOnce(new Error('Unprocessable Entity: "Line could not be resolved"')) + // Body-only COMMENT fallback — success + .mockResolvedValueOnce({ + data: { + id: 800, + html_url: "https://github.com/owner/repo/pull/42#pullrequestreview-800", + }, + }); + + const result = await buffer.submitReview(); + + expect(result.success).toBe(true); + expect(result.event).toBe("COMMENT"); + expect(mockGithub.rest.pulls.createReview).toHaveBeenCalledTimes(3); + const bodyOnlyArgs = mockGithub.rest.pulls.createReview.mock.calls[2][0]; + expect(bodyOnlyArgs.event).toBe("COMMENT"); + expect(bodyOnlyArgs.comments).toBeUndefined(); + expect(bodyOnlyArgs.body).toContain("Inline comment"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("COMMENT retry on own PR failed with unresolvable line(s)")); + }); + it("should skip (success:true, skipped:true) when PR is permanently locked after all retries", async () => { const setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation(handler => { if (typeof handler === "function") { @@ -1417,6 +1452,63 @@ describe("pr_review_buffer (factory pattern)", () => { expect(mockGithub.rest.pulls.createReview).toHaveBeenCalledTimes(2); }); + it("should retry body-only as COMMENT when body-only REQUEST_CHANGES is rejected on own PR after line-resolution failure", async () => { + buffer.addComment({ path: "file.go", line: 5, body: "Nil dereference on this line" }); + buffer.setReviewMetadata("Fix the blocking issues.", "REQUEST_CHANGES"); + buffer.setReviewContext({ + repo: "owner/repo", + repoParts: { owner: "owner", repo: "repo" }, + pullRequestNumber: 47375, + pullRequest: { head: { sha: "abc123" }, user: { login: "linter-miner[bot]" } }, + }); + + mockGithub.rest.pulls.createReview + // Initial attempt — line resolution fails (this triggers the body-only path) + .mockRejectedValueOnce(new Error('Unprocessable Entity: "Line could not be resolved"')) + // Body-only REQUEST_CHANGES — own-PR 422 + .mockRejectedValueOnce(new Error("Can not request changes on your own pull request")) + // Body-only COMMENT retry — success + .mockResolvedValueOnce({ + data: { + id: 900, + html_url: "https://github.com/owner/repo/pull/47375#pullrequestreview-900", + }, + }); + + const result = await buffer.submitReview(); + + expect(result.success).toBe(true); + expect(result.event).toBe("COMMENT"); + expect(mockGithub.rest.pulls.createReview).toHaveBeenCalledTimes(3); + const bodyOnlyCommentArgs = mockGithub.rest.pulls.createReview.mock.calls[2][0]; + expect(bodyOnlyCommentArgs.event).toBe("COMMENT"); + expect(bodyOnlyCommentArgs.comments).toBeUndefined(); + expect(bodyOnlyCommentArgs.body).toContain("Nil dereference on this line"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Body-only REQUEST_CHANGES review rejected on own PR")); + }); + + it("should return failure when body-only COMMENT retry also fails on own PR", async () => { + buffer.addComment({ path: "file.go", line: 5, body: "Review comment" }); + buffer.setReviewMetadata("Feedback.", "REQUEST_CHANGES"); + buffer.setReviewContext({ + repo: "owner/repo", + repoParts: { owner: "owner", repo: "repo" }, + pullRequestNumber: 42, + pullRequest: { head: { sha: "abc123" } }, + }); + + mockGithub.rest.pulls.createReview + .mockRejectedValueOnce(new Error("Line could not be resolved")) + .mockRejectedValueOnce(new Error("Can not request changes on your own pull request")) + .mockRejectedValueOnce(new Error("Unexpected server error")); + + const result = await buffer.submitReview(); + + expect(result.success).toBe(false); + expect(result.error).toContain("Unexpected server error"); + expect(mockGithub.rest.pulls.createReview).toHaveBeenCalledTimes(3); + }); + it("should escape HTML-sensitive characters in fallback summary and body", async () => { buffer.addComment({ path: "src/&\"'.js",