Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions actions/setup/js/pr_review_buffer.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial retry may still fail silently if the GitHub API short-circuits its error list — the 422 response may not enumerate every unresolvable index, leaving undetected bad comments in resolvableComments.

💡 Details

The GitHub API often short-circuits validation after the first error in a batch. If there are two unresolvable comments but the 422 response only identifies index 1, then index 3 (also bad) remains in resolvableComments. The partial retry will fail again, the catch block fires, a warning is logged, and then the function falls through to the existing body-only fallback — which is correct but means the partial retry consumed an extra round-trip for nothing, and the user-visible warning is confusing.

This is an inherent API limitation, but it's worth documenting with a comment so future maintainers don't assume the index list is exhaustive. It also means the test case (which mocks a single-index error) doesn't cover the more common real-world scenario.

if (unresolvableCommentIndices.length > 0 && unresolvableCommentIndices.length < comments.length) {
const unresolvableCommentIndexSet = new Set(unresolvableCommentIndices);
const resolvableComments = comments.filter((_, index) => !unresolvableCommentIndexSet.has(index));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test only covers the "middle comment unresolvable" scenario. Missing edge cases: all comments unresolvable (should fall through to body-only), exactly one comment unresolvable at index 0, and the partial-retry itself failing (the inner catch path).

💡 Missing test cases
  1. All comments unresolvableunresolvableCommentIndices.length === comments.length should skip partial retry and fall through to body-only.
  2. Index 0 unresolvable — verifies off-by-one safety in the filter.
  3. Partial retry also throws — the inner catch path logs a warning and falls through to body-only; this path has no test coverage.

@copilot please address this.

const unresolvableComments = comments.filter((_, index) => unresolvableCommentIndexSet.has(index));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial retry may silently drop the review body when requestParams.body is not a string — the empty-string fallback discards the entire review body.

💡 Details
body: appendUnanchoredCommentsSection(
  typeof requestParams.body === "string" ? requestParams.body : "",
  unresolvableComments
),

When requestParams.body is undefined or null, this passes "" to appendUnanchoredCommentsSection, silently discarding the AI-generated review summary. The existing body-only fallback at line 686 avoids this problem by spreading requestParams directly.

Suggested fix:

body: appendUnanchoredCommentsSection(requestParams.body ?? "", unresolvableComments),

Or assert the invariant that body is always a string by this point.

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.`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment_count in the success result is understated — only resolvableComments.length is reported, not the total number of comments processed.

💡 Details
return buildReviewSuccessResult(review, event, resolvableComments.length, afterState);

unresolvableComments were still submitted as part of the review body, so they are not lost — but any caller that consumes result.comment_count to validate, log, or display how many comments were reviewed will see a count that excludes the demoted comments. This can silently mislead downstream logic (e.g. "expected N comments, got M") or surface confusing metrics.

Suggested fix: pass the total original comment count, or add a separate field (e.g. anchored_comment_count / body_fallback_comment_count) so callers can distinguish the two.

);
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] comment_count in the success result reflects only resolvable inline comments, not the unresolvable ones moved to the review body — callers relying on this count may see a misleadingly low number.

💡 Suggested fix

Return total comment count to preserve the existing contract:

return buildReviewSuccessResult(review, event, resolvableComments.length + unresolvableComments.length, afterState);

If buildReviewSuccessResult distinguishes inline vs body counts, thread both through explicitly.

@copilot please address this.

const afterState = await fetchAfterStateIfAvailable();
core.info(`Created PR review #${review.id} (partial-anchor fallback): ${review.html_url}`);
return buildReviewSuccessResult(review, event, resolvableComments.length, afterState);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment_count undercounts preserved comments in partial-anchor fallback

On the partial-anchor success path, buildReviewSuccessResult is called with resolvableComments.length as the comment count. But unresolvable comments are not lost — they are preserved in the review body. Callers relying on comment_count will see a lower number than actual total feedback delivered, skewing any downstream reporting/metrics.

Consider returning comments.length (the full original count) or adding separate anchored_comment_count / unanchored_comment_count fields to the result.

@copilot please address this.

} catch (partialRetryError) {
core.warning(`Failed to submit partially anchored PR review: ${getErrorMessage(partialRetryError)}. Falling back to body-only review.`);
}
Comment on lines +688 to +690

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in commit 3b8cdb2. The new test ("should fall back to body-only with all comments when partial-anchor retry also fails") uses three mock calls: the first fails with an indexed-error response, the second (partial retry) also rejects, and the third (body-only) succeeds. It then asserts that mockGithub.rest.pulls.createReview was called exactly three times, the third call has no comments property, and the body contains all three original comments in the fallback section.

}

core.warning(`PR review submission failed due to unresolvable comment line(s): ${errorMessage}. Retrying as body-only review.`);
try {
const bodyOnlyParams = { ...requestParams };
Expand Down Expand Up @@ -825,6 +851,56 @@ function createPrReviewBufferRegistry() {
}

module.exports = { createReviewBuffer, createPrReviewBufferRegistry };

/**
* Parse API validation errors to identify specific inline comments that could not be resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] extractUnresolvableCommentIndices is defined after module.exports — it is hoisted as a function declaration so it works today, but this placement is non-idiomatic and risks a future refactor breaking hoisting (e.g. if the function is converted to a const arrow). Move it above module.exports alongside the other helpers.

@copilot please address this.

* 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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] The field regex /comments(?:\[|\.)(\d+)(?:\]|\.|$)/ accepts dot-notation (comments.1.line) which GitHub's API does not emit — but more importantly it won't match comments[1] (no trailing char) when the field is exactly "comments[1]". The trailing group (?:\]|\.|$) requires a char after ], so a bare "comments[1]" (common in GitHub 422 payloads) silently drops the index.

💡 Suggested fix
const fieldMatch = field.match(/^comments\[(\d+)\]/);

This anchors to the exact GitHub format and removes the ambiguous dot-notation branch.

Add a test case with field: "comments[1]" (no trailing content) to cover the regression.

@copilot please address this.

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
Expand Down
84 changes: 84 additions & 0 deletions actions/setup/js/pr_review_buffer.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<details><summary>src/unresolved.js:99</summary>");
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" });
Expand Down
Loading