Skip to content

Mitigate safe-outputs review failures when inline comment anchors are partially invalid#47356

Merged
pelikhan merged 3 commits into
mainfrom
copilot/investigate-comment-creation-failure
Jul 22, 2026
Merged

Mitigate safe-outputs review failures when inline comment anchors are partially invalid#47356
pelikhan merged 3 commits into
mainfrom
copilot/investigate-comment-creation-failure

Conversation

Copilot AI commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Safe-outputs review submission can fail entirely when a batched pulls.createReview includes at least one unresolvable inline anchor (422: "Line could not be resolved" / "Path could not be resolved"). This change preserves valid inline feedback by degrading only the invalid subset instead of collapsing the whole review to body-only.

  • Failure mode addressed

    • submitReview previously retried as fully body-only whenever any inline anchor was unresolvable.
    • Result: resolvable inline comments lost anchoring and signal quality.
  • Fallback strategy upgrade

    • Parse GitHub validation errors to extract per-comment indexes (e.g. comments[1].line).
    • Split buffered comments into:
      • resolvable inline comments (kept as inline)
      • unresolvable comments (moved to fallback body section)
    • Retry with mixed mode (inline + fallback body section).
    • Preserve existing full body-only fallback if mixed retry also fails.
  • Implementation scope

    • actions/setup/js/pr_review_buffer.cjs
      • Added indexed-error extraction helper for unresolvable comment detection.
      • Updated unresolved-anchor recovery path in submitReview to prefer partial anchoring.
  • Coverage

    • actions/setup/js/pr_review_buffer.test.cjs
      • Added case verifying indexed API errors trigger partial inline preservation and fallback-body inclusion only for invalid comments.
const unresolved = extractUnresolvableCommentIndices(error, comments.length);
const resolvable = comments.filter((_, i) => !new Set(unresolved).has(i));
const unresolvable = comments.filter((_, i) => new Set(unresolved).has(i));

await createReviewWithRetry({
  ...requestParams,
  comments: resolvable,
  body: appendUnanchoredCommentsSection(requestParams.body ?? "", unresolvable),
});

Generated by 👨‍🍳 PR Sous Chef · gpt54 8.13 AIC · ⌖ 8.71 AIC · ⊞ 7K ·
Comment /souschef to run again

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title Mitigate safe-outputs PR review failures from unresolved inline anchors Mitigate safe-outputs review failures when inline comment anchors are partially invalid Jul 22, 2026
Copilot AI requested a review from pelikhan July 22, 2026 15:49
@pelikhan
pelikhan marked this pull request as ready for review July 22, 2026 15:51
Copilot AI review requested due to automatic review settings July 22, 2026 15:51
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #47356 does not have the 'implementation' label and has 0 new lines of code in business logic directories (threshold: 100).

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

Copilot AI left a comment

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.

Pull request overview

Improves safe-output review recovery by preserving valid inline comments when only some anchors fail.

Changes:

  • Extracts invalid comment indexes from GitHub validation errors.
  • Retries reviews with valid inline comments and moves invalid ones into the body.
  • Adds coverage for successful partial fallback.
Show a summary per file
File Description
actions/setup/js/pr_review_buffer.cjs Implements partial-anchor fallback logic.
actions/setup/js/pr_review_buffer.test.cjs Tests successful mixed inline/body retry.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Medium

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

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.

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 1 test(s): 1 design, 0 implementation, 0 violation(s).

📊 Metrics (1 test)
Metric Value
Analyzed 1 (Go: 0, JS: 1)
✅ Design 1 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 1 (100%)
Duplicate clusters 0
Inflation No (0.5:1)
🚨 Violations 0
Test File Classification Issues
should retry with resolvable inline comments when API identifies specific unresolvable comment indexes pr_review_buffer.test.cjs design_test None

Verdict

Passed. 0% implementation tests (threshold: 30%). The new test exercises the error-retry path end-to-end with 9 behavioral assertions covering success state, retry call count, filtered comment list, fallback body content, and warning message.

🧪 Test quality analysis by Test Quality Sentinel · sonnet46 31.5 AIC · ⌖ 10.5 AIC · ⊞ 7K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

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.

✅ Test Quality Sentinel: 100/100. 0% implementation tests (threshold: 30%). The new test is a well-structured design test covering the error-retry behavioral contract with 9 assertions.

@github-actions github-actions Bot mentioned this pull request Jul 22, 2026

@github-actions github-actions Bot left a comment

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.

Review: Partial-anchor fallback for inline comment resolution

The approach is well-reasoned and the test coverage is solid. One non-blocking issue found.

What works well

  • extractUnresolvableCommentIndices correctly walks error, error.originalError, and error.cause for robustness.
  • The regex /comments(?:\[|\.)(\d+)(?:\]|\.|$)/ handles both bracket and dot notation for field names.
  • Bounds-checking the parsed index against totalComments prevents out-of-range access.
  • The fallback chain (partial → body-only) correctly preserves existing behavior when partial retry also fails.
  • Test directly validates the retry call arguments and body content.

One issue (see inline comment): comment_count in the success result only counts resolvable inline comments, not the unresolvable ones preserved in the body — see inline comment on line 687.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 21.2 AIC · ⌖ 4.59 AIC · ⊞ 5K

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

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.

@github-actions github-actions Bot left a comment

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.

Skills-Based Review 🧠

Applied /diagnosing-bugs and /tdd — requesting changes on a correctness issue and coverage gaps.

📋 Key Themes & Highlights

Key Themes

  • comment_count under-reports (line 684): the success result counts only resolvable inline comments, silently dropping unresolvable ones from the metric.
  • Regex edge case (line 886): /comments(?:\[|\.)(\d+)(?:\]|\.|$)/ fails to match the bare "comments[1]" field format that GitHub actually emits in 422 responses — the trailing group requires a character after ].
  • Missing test coverage (line 670): the inner catch path (partial retry also fails → fall through to body-only) and the "all comments unresolvable" branch have no tests.
  • Function placement (line 856): extractUnresolvableCommentIndices is declared after module.exports, which works due to hoisting but is fragile.

Positive Highlights

  • ✅ Clean two-phase fallback strategy (partial-anchor → body-only) — well-structured and preserves the existing safety net.
  • ✅ Error path is well-logged with actionable core.warning messages.
  • ✅ New test covers the happy-path partial-anchor scenario clearly with Arrange/Act/Assert structure.
  • candidateErrors chain (error, originalError, cause) is a solid defensive pattern for Octokit error shapes.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 36.8 AIC · ⌖ 4.96 AIC · ⊞ 6.7K
Comment /matt to run again

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.

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

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.

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.

@github-actions github-actions Bot left a comment

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.

Review: Partial inline-anchor fallback

Non-blocking observations — the fallback chain is sound but has a few rough edges worth addressing before this pattern is extended further.

📋 Findings (3 medium)
  1. Line 671 — review body may be silently dropped: the typeof body === 'string' guard replaces undefined/null with "", losing the built review body. Use ?? "" instead, or assert the invariant.

  2. Line 675 — comment_count understated: resolvableComments.length excludes demoted body-fallback comments. Callers expecting total count will see a misleading value.

  3. Line 667 — API may not enumerate all unresolvable indices: GitHub 422 responses can short-circuit after the first error. When they do, the partial retry will include additional bad comments, fail again, and fall through to body-only — which is safe but wastes a round-trip and emits a confusing warning. Worth a comment so future maintainers don't assume the index list is complete.

The implementation is otherwise correct: the Set-based filter, regex parsing, fallback chain ordering, and test coverage for the happy path all look good.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 40 AIC · ⌖ 4.88 AIC · ⊞ 5.7K
Comment /review to run again

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

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.

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

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Excellent work on this PR! 👏 You have tackled a tricky edge case in PR review submission where some inline comments fail to anchor while others succeed.

What stands out:

  • Sharp problem definition — clearly identified that collapsing the entire review to body-only loses signal quality from valid comments.
  • Thoughtful recovery strategy — instead of all-or-nothing, you parse API validation errors to identify which comments failed and retry with a mixed approach (inline + fallback body section).
  • Solid test coverage — the two new test cases thoroughly validate both the partial-anchor success path and the cascade into full body-only fallback.
  • Defensive coding — the error parsing handles multiple error wrapping layers (originalError, cause) and validates indices before use.

This is exactly the kind of resilient error handling that makes for a great user experience — reviewers wontlosevaluablefeedbackwhenGitHubs validation is finicky about line numbers or paths.

The PR is ready for review! ✅

Generated by ✅ Contribution Check · sonnet46 44.4 AIC · ⌖ 12.1 AIC · ⊞ 6.2K ·

@pelikhan
pelikhan merged commit 3309d26 into main Jul 22, 2026
8 checks passed
@pelikhan
pelikhan deleted the copilot/investigate-comment-creation-failure branch July 22, 2026 17:38
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.83.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants