diff --git a/actions/setup/js/pr_review_buffer.cjs b/actions/setup/js/pr_review_buffer.cjs index ac00d563dd1..1417445f2ab 100644 --- a/actions/setup/js/pr_review_buffer.cjs +++ b/actions/setup/js/pr_review_buffer.cjs @@ -664,6 +664,32 @@ function createReviewBuffer() { // body-only review so that the overall review (and its footer body) is still submitted // successfully. Matches both "Line could not be resolved" and "Path could not be resolved". if ((errorMessage.includes("Line could not be resolved") || errorMessage.includes("Path could not be resolved")) && comments.length > 0) { + const unresolvableCommentIndices = extractUnresolvableCommentIndices(error, comments.length); + if (unresolvableCommentIndices.length > 0 && unresolvableCommentIndices.length < comments.length) { + const unresolvableCommentIndexSet = new Set(unresolvableCommentIndices); + const resolvableComments = comments.filter((_, index) => !unresolvableCommentIndexSet.has(index)); + const unresolvableComments = comments.filter((_, index) => unresolvableCommentIndexSet.has(index)); + core.warning( + `PR review submission failed due to unresolvable comment line(s): ${errorMessage}. ` + + `Retrying with ${resolvableComments.length} resolvable inline comment(s); ` + + `${unresolvableComments.length} comment(s) will be appended to the review body.` + ); + try { + const partialParams = { + ...requestParams, + comments: resolvableComments, + body: appendUnanchoredCommentsSection(typeof requestParams.body === "string" ? requestParams.body : "", unresolvableComments), + }; + const { data: review } = await createReviewWithRetry(partialParams); + await maybeSupersedeOlderReviews(review.id); + const afterState = await fetchAfterStateIfAvailable(); + core.info(`Created PR review #${review.id} (partial-anchor fallback): ${review.html_url}`); + return buildReviewSuccessResult(review, event, resolvableComments.length, afterState); + } catch (partialRetryError) { + core.warning(`Failed to submit partially anchored PR review: ${getErrorMessage(partialRetryError)}. Falling back to body-only review.`); + } + } + core.warning(`PR review submission failed due to unresolvable comment line(s): ${errorMessage}. Retrying as body-only review.`); try { const bodyOnlyParams = { ...requestParams }; @@ -825,6 +851,56 @@ function createPrReviewBufferRegistry() { } module.exports = { createReviewBuffer, createPrReviewBufferRegistry }; + +/** + * Parse API validation errors to identify specific inline comments that could not be resolved. + * Returns 0-based indexes corresponding to items in the buffered comments array. + * + * @param {unknown} error + * @param {number} totalComments + * @returns {number[]} + */ +function extractUnresolvableCommentIndices(error, totalComments) { + const indices = new Set(); + // prettier-ignore + const errorAsAny = /** @type {any} */ (error); + const candidateErrors = [errorAsAny, errorAsAny?.originalError, errorAsAny?.cause]; + + for (const candidate of candidateErrors) { + if (!candidate || typeof candidate !== "object") { + continue; + } + // prettier-ignore + const candidateAsAny = /** @type {any} */ (candidate); + const apiErrors = candidateAsAny.response && candidateAsAny.response.data && Array.isArray(candidateAsAny.response.data.errors) ? candidateAsAny.response.data.errors : null; + if (!apiErrors) { + continue; + } + + for (const apiError of apiErrors) { + const field = typeof apiError?.field === "string" ? apiError.field : ""; + const message = typeof apiError?.message === "string" ? apiError.message : ""; + if (!message.includes("Line could not be resolved") && !message.includes("Path could not be resolved")) { + continue; + } + + const fieldMatch = field.match(/comments(?:\[|\.)(\d+)(?:\]|\.|$)/); + if (!fieldMatch) { + continue; + } + + const parsedIndex = Number.parseInt(fieldMatch[1], 10); + if (!Number.isInteger(parsedIndex) || parsedIndex < 0 || parsedIndex >= totalComments) { + continue; + } + + indices.add(parsedIndex); + } + } + + return Array.from(indices).sort((a, b) => a - b); +} + /** * Append a fallback section that preserves inline comment content when comments cannot be anchored. * @param {string} reviewBody diff --git a/actions/setup/js/pr_review_buffer.test.cjs b/actions/setup/js/pr_review_buffer.test.cjs index fef7d2064ee..0999f1ed260 100644 --- a/actions/setup/js/pr_review_buffer.test.cjs +++ b/actions/setup/js/pr_review_buffer.test.cjs @@ -1224,6 +1224,90 @@ describe("pr_review_buffer (factory pattern)", () => { expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Line could not be resolved")); }); + it("should retry with resolvable inline comments when API identifies specific unresolvable comment indexes", async () => { + buffer.addComment({ path: "src/valid-one.js", line: 11, body: "First resolvable inline comment" }); + buffer.addComment({ path: "src/unresolved.js", line: 99, body: "This one cannot be anchored" }); + buffer.addComment({ path: "src/valid-two.js", line: 22, body: "Second resolvable inline comment" }); + buffer.setReviewMetadata("Reviewed with comments.", "REQUEST_CHANGES"); + buffer.setReviewContext({ + repo: "owner/repo", + repoParts: { owner: "owner", repo: "repo" }, + pullRequestNumber: 21946, + pullRequest: { head: { sha: "abc123" } }, + }); + + const unresolvedError = new Error('Unprocessable Entity: "Line could not be resolved"'); + // @ts-ignore - Simulate Octokit error response payload with indexed comment field. + unresolvedError.response = { data: { errors: [{ field: "comments[1].line", message: "Line could not be resolved" }] } }; + + mockGithub.rest.pulls.createReview.mockRejectedValueOnce(unresolvedError).mockResolvedValueOnce({ + data: { + id: 803, + html_url: "https://github.com/owner/repo/pull/21946#pullrequestreview-803", + }, + }); + + const result = await buffer.submitReview(); + + expect(result.success).toBe(true); + expect(result.review_id).toBe(803); + expect(result.comment_count).toBe(2); + expect(mockGithub.rest.pulls.createReview).toHaveBeenCalledTimes(2); + const retryArgs = mockGithub.rest.pulls.createReview.mock.calls[1][0]; + expect(retryArgs.comments).toHaveLength(2); + expect(retryArgs.comments.map(comment => comment.path)).toEqual(["src/valid-one.js", "src/valid-two.js"]); + expect(retryArgs.body).toContain("### Comments that could not be inline-anchored"); + expect(retryArgs.body).toContain("
src/unresolved.js:99"); + expect(retryArgs.body).toContain("This one cannot be anchored"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Retrying with 2 resolvable inline comment(s)")); + }); + + it("should fall back to body-only with all comments when partial-anchor retry also fails", async () => { + buffer.addComment({ path: "src/valid-one.js", line: 11, body: "First resolvable inline comment" }); + buffer.addComment({ path: "src/unresolved.js", line: 99, body: "This one cannot be anchored" }); + buffer.addComment({ path: "src/valid-two.js", line: 22, body: "Second resolvable inline comment" }); + buffer.setReviewMetadata("Reviewed with partial failures.", "COMMENT"); + buffer.setReviewContext({ + repo: "owner/repo", + repoParts: { owner: "owner", repo: "repo" }, + pullRequestNumber: 21946, + pullRequest: { head: { sha: "abc123" } }, + }); + + const unresolvedError = new Error('Unprocessable Entity: "Line could not be resolved"'); + // @ts-ignore - Simulate Octokit error response payload with indexed comment field. + unresolvedError.response = { data: { errors: [{ field: "comments[1].line", message: "Line could not be resolved" }] } }; + + mockGithub.rest.pulls.createReview + .mockRejectedValueOnce(unresolvedError) + .mockRejectedValueOnce(new Error("Partial retry also failed")) + .mockResolvedValueOnce({ + data: { + id: 805, + html_url: "https://github.com/owner/repo/pull/21946#pullrequestreview-805", + }, + }); + + const result = await buffer.submitReview(); + + expect(result.success).toBe(true); + expect(result.review_id).toBe(805); + expect(result.comment_count).toBe(0); + expect(mockGithub.rest.pulls.createReview).toHaveBeenCalledTimes(3); + // Third call (body-only fallback) must have no comments + const bodyOnlyArgs = mockGithub.rest.pulls.createReview.mock.calls[2][0]; + expect(bodyOnlyArgs.comments).toBeUndefined(); + // All three original comments must appear in the fallback body + expect(bodyOnlyArgs.body).toContain("### Comments that could not be inline-anchored"); + expect(bodyOnlyArgs.body).toContain("src/valid-one.js"); + expect(bodyOnlyArgs.body).toContain("First resolvable inline comment"); + expect(bodyOnlyArgs.body).toContain("src/unresolved.js"); + expect(bodyOnlyArgs.body).toContain("This one cannot be anchored"); + expect(bodyOnlyArgs.body).toContain("src/valid-two.js"); + expect(bodyOnlyArgs.body).toContain("Second resolvable inline comment"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to submit partially anchored PR review")); + }); + it("should retry as body-only review when Path could not be resolved error occurs", async () => { buffer.addComment({ path: ".changeset/some-file.md", line: 1, body: "Review comment on line 1" }); buffer.addComment({ path: "src/new_file.js", line: 42, body: "A second inline comment" });