Skip to content

fix(reviewplan): stop re-posting a finding already raised on the PR - #579

Open
piekstra wants to merge 2 commits into
mainfrom
piekstra/dedupe-settled-findings
Open

fix(reviewplan): stop re-posting a finding already raised on the PR#579
piekstra wants to merge 2 commits into
mainfrom
piekstra/dedupe-settled-findings

Conversation

@piekstra

Copy link
Copy Markdown
Contributor

What happens today

A finding that a later commit fixes is still present in the cumulative base..head diff, so it anchors again on the next review. reconcileInline matches only on run id, action id, and head/base SHA, none of which a later run reproduces. Nothing anywhere compares a new finding to the threads already on the PR.

The result is the same finding posted as a brand-new thread on every review, byte-identical, against source text that no longer exists at head. Replying and resolving does not stop it. A reader who sees that learns to skim review comments rather than read them, which costs more than the duplicate itself.

What this changes

reviewplan.Request gains ExistingThreads, and Build keeps a finding out of a second inline thread when this identity already opened one saying the same thing about the same file. The pipeline populates it from the threads it already fetches, so there is no extra request.

Deliberate choices, each of which could reasonably have gone the other way:

  • File and text, never line. A fix shifts the lines around it, so the repeats worth suppressing are exactly the ones whose line moved.
  • Exact equality after normalization, not similarity. A near-match is a different claim often enough that fuzzy matching would lose real findings. The repeats this exists for are identical.
  • HTML comments stripped before comparing. Markers carry a run id that differs on every run; leaving them in would make every comparison fail and suppress nothing.
  • Only threads this identity authored to report a finding. A human quoting the same code is not the review repeating itself, and must not suppress a finding.
  • Resolved and open threads treated alike. Both mean it has been said, and the open one is still there to be read.
  • Demoted to the review body, not dropped. A reviewer that still believes the finding stays on record; only the duplicate thread goes away.

Tests

internal/reviewplan/dedupe_raised_test.go builds a plan, takes the body it produced, and feeds it back as an existing thread, which is what a later run actually reads from the host:

  • a repeat is suppressed, resolved or open
  • a repeat whose line moved is still suppressed
  • a repeat is suppressed despite a different run marker, using a body with a real marker prepended the way the host stores it
  • a different finding on the same file, and the same text about a different file, are both still posted
  • an empty or marker-only thread suppresses nothing
  • an unterminated HTML comment does not leak marker text into the comparison

internal/pipeline/existing_threads_test.go covers the filter: only this identity's finding threads are carried, the opening comment is used rather than a reply, resolution is carried through rather than filtered on, and a thread with no comments is skipped.

Verification

go build ./..., make lint (0 issues), and the full suite pass, except TestPiRPCReviewerExtensionLoadsInInstalledPi, which fails identically on unmodified main in this environment (Error: Unknown options: --no-builtin-tools, --no-approve) and is unrelated to this change.

A finding fixed by a later commit is still present in the cumulative base..head
diff, so it anchors again on the next review. Posting is idempotent on run id and
action id, which a later run never matches. Nothing anywhere compared a new
finding to the threads already on the PR, so the same finding could be posted
again on every run, most visibly against source text that no longer exists at
head. A reader who sees that learns to skim review comments rather than read
them.

The planner now takes the threads this identity already opened and keeps a
finding out of a second inline thread when one of them says the same thing about
the same file.

Matching is by file and text, never by line: a fix shifts the lines around it,
and the repeats worth suppressing are the ones that moved. Everything the
planner adds to a posted comment is stripped back off first, along with any HTML
comments, because markers carry a run id that differs on every run and would
otherwise make every comparison fail. Equality is exact once formatting is
folded, since a near-match is a different claim often enough that suppressing it
would lose real findings.

Only threads this identity authored to report a finding are considered. A human
quoting the same code is not the review repeating itself.

Demotion, not deletion: the finding stays in the review body, so a reviewer that
still believes it remains on record, and an unresolved thread is still there to
be read. Resolved and open threads are treated alike; both mean it has been
said.

@monit-reviewer monit-reviewer left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated PR Review

Reviewed commit: 8b15d594002a
Profile: claude-monit-reviewer - Posting as: monit-reviewer

Summary

Reviewer Findings
go:implementation-tests 0
structure:repo-health 1
architecture:solid-reviewer-agnostic 3
structure:harness-engineering 0
structure:repo-health (1 finding)

Minor - internal/reviewplan/reviewplan.go:668

stripHTMLComments strips every <!-- ... --> span, but the marker it exists to remove is specifically &lt;!-- codereview:... -->; threadcontext.SanitizeBody, which already sanitizes the same comment bodies before this package ever sees them, only matches that codereview: prefix and leaves other HTML comments intact. A finding whose body legitimately quotes diff text containing an unrelated HTML/XML/JSX comment (a plausible thing for a review tool to quote) gets that quoted text silently dropped from the dedupe key here but not from the posted body, so the comparison can diverge from what was actually posted - two findings that differ only in a quoted comment's content would collapse to the same key and one would be wrongly suppressed, and an unterminated quoted comment (e.g. a code block illustrating a bug in an unclosed comment) truncates the rest of the body from the comparison entirely. Reuse threadcontext.SanitizeBody (or match its &lt;!-- codereview: prefix) here instead of a second, broader hand-rolled stripper, so the two packages agree on what counts as a marker.

architecture:solid-reviewer-agnostic (3 findings)

Major - internal/reviewplan/reviewplan.go:661

U-S1 / U-G1: normalizeFindingText re-implements knowledge that belongs to commentBody (reviewplan.go:869-875), and the two are already out of sync. commentBody renders a file-level fallback as fileLevelFallbackPrefix + FilePath + "\n\n" + body + "\n\n" + inlineFooter; normalization strips the prefix literal and the footer but leaves the interpolated file path in the text, so the stored body normalizes to "main.go finding body ..." while the candidate normalizes to "finding body ..." and never matches.

GitHub reports NativeFileLevelComments: false (internal/gitprovider/github/client.go:179), so every file-anchored finding on GitHub takes the fallback path - the whole class this feature exists for, since a file-level finding re-anchors to the first hunk on each run. Reproduced with a probe test: ProviderCaps{NativeFileLevelComments: false} plus a file-anchored finding, feeding Build's own posted body back as an ExistingThread, still plans 1 inline comment (want 0). The suite does not cover it because baseRequest() sets NativeFileLevelComments: true and an inline anchor.

Fix: derive the key for the candidate from the same renderer the host saw, i.e. findingKey(anchored.FilePath, commentBody(anchored)) in demoteFindingsAlreadyRaised, so both sides pass through one decoration path and future changes to commentBody cannot silently break matching. Add a fallback-anchoring case to dedupe_raised_test.go to lock it in.

Major - internal/pipeline/pipeline.go:2358

U-L1: the filter does not implement its own documented contract. The doc comment says these are "the ones this identity opened to report a finding", but Status.CRAuthoredFinding is set by threadcontext.statusForComments when any comment by the posting identity carries a finding or a thread-reply marker (internal/threadcontext/threadcontext.go:230). A thread a human opened and this identity merely replied to therefore qualifies, and thread.Comments[0].Body then carries the human's text into the dedupe key.

Reproduced against the real normalizer (human opens main.go with body finding body, bot replies with a thread_reply marker): CRAuthoredFinding=true, carried {Path:main.go Body:finding body Resolved:false}. That is exactly the case the PR intent rules out ("A human quoting the same code is not the review repeating itself, and must not suppress a finding") - a human who quotes or restates a finding can now silence it, and the threads that are carried are keyed on the wrong text.

Fix: gate on the opening comment rather than thread-level status, e.g.

if len(thread.Comments) == 0 {
    continue
}
first := thread.Comments[0]
if !first.AuthoredByPostingIdentity || !first.HasFindingMarker {
    continue
}

Both fields already exist on threadcontext.Comment, so this needs no new provider data. internal/pipeline/existing_threads_test.go constructs Status{CRAuthoredFinding: ...} by hand and so cannot catch this; a case built through threadcontext.Normalize would.

Minor - internal/reviewplan/reviewplan.go:111

U-G1 / U-O1: ExistingThread.Resolved is new exported surface with no consumer. demoteFindingsAlreadyRaised deliberately treats resolved and open threads alike, findingKey never reads it, and the pipeline only populates it; the only assertions on it are in the new tests. A field on a public request type that nothing branches on invites callers to think it changes behavior and will drift.

Either drop it (the doc comment on demoteFindingsAlreadyRaised already records why resolution is irrelevant) or, if it is meant to become a discriminator later, add it when a consumer exists.

Reviewer Coverage

  • go:implementation-tests — complete (broad); skipped: none; constraints: none
  • structure:repo-health — complete (broad); skipped: none; constraints: none
  • architecture:solid-reviewer-agnostic — complete (broad); inspected 2 assigned files (4 inspected across reviewers): internal/pipeline/pipeline.go, internal/reviewplan/reviewplan.go; skipped: none; constraints: Findings 1 and 2 were reproduced with throwaway probe tests in a scratch copy of the repo (go test), not by inspection alone. Scope limited to the two assigned files; the two new test files were read for context only.
  • structure:harness-engineering — complete (broad); inspected 2 assigned files (4 inspected across reviewers): internal/pipeline/pipeline.go, internal/reviewplan/reviewplan.go; skipped: none; constraints: none
Inspected files (4)
  • internal/pipeline/existing_threads_test.go
  • internal/pipeline/pipeline.go
  • internal/reviewplan/dedupe_raised_test.go
  • internal/reviewplan/reviewplan.go

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 5m 52s | ~$6.82 (est.) | claude-sonnet-5, claude-opus-5 | cr 0.10.294
Field Value
Model claude-sonnet-5, claude-opus-5
Reviewers go:implementation-tests, structure:repo-health, architecture:solid-reviewer-agnostic, structure:harness-engineering
Engine claude_cli · claude-sonnet-5, claude-opus-5
Reviewed by cr · monit-reviewer
Duration 5m 52s wall · 16m 42s compute
Cost ~$6.82 (est.)
Tokens 172 in / 50.3k out

Per-workstream usage

  • orchestrator-selection — claude-sonnet-5
    • In: 6
    • Out: 3.2k
    • Cache read: 117.8k
    • Cache create: 97.7k
    • Cost: ~$0.45 (est.)
    • Duration: 37s
  • go:implementation-tests — claude-sonnet-5
    • In: 42
    • Out: 8.9k
    • Cache read: 1.7M
    • Cache create: 149.3k
    • Cost: ~$1.21 (est.)
    • Duration: 3m 58s
  • structure:repo-health — claude-sonnet-5
    • In: 50
    • Out: 15.5k
    • Cache read: 2.2M
    • Cache create: 156.9k
    • Cost: ~$1.48 (est.)
    • Duration: 3m 34s
  • architecture:solid-reviewer-agnostic — claude-opus-5
    • In: 50
    • Out: 15.8k
    • Cache read: 2.1M
    • Cache create: 150.2k
    • Cost: ~$2.40 (est.)
    • Duration: 4m 40s
  • structure:harness-engineering — claude-sonnet-5
    • In: 20
    • Out: 5.5k
    • Cache read: 695.6k
    • Cache create: 135.5k
    • Cost: ~$0.80 (est.)
    • Duration: 3m 28s
  • orchestrator-rollup — claude-sonnet-5
    • In: 4
    • Out: 1.3k
    • Cache read: 40.6k
    • Cache create: 121.3k
    • Cost: ~$0.49 (est.)
    • Duration: 22s

Comment thread internal/reviewplan/reviewplan.go Outdated
Comment thread internal/reviewplan/reviewplan.go Outdated
Comment thread internal/pipeline/pipeline.go Outdated
Comment thread internal/reviewplan/reviewplan.go Outdated
Three corrections, two of which meant the suppression could not fire on the
case it exists for.

The file-level fallback header interpolates the file path after its prefix.
Stripping only the literal prefix left the path in the text, so a stored
file-level comment normalized to "main.go finding body" while the finding it
came from normalized to "finding body" and never matched. On a host without
native file-level comments that is every file-anchored finding, and such a
finding re-anchors to the first hunk on every run, so it is the class most in
need of this.

Stripping every HTML comment took text out of the comparison that stays in the
posted body. A finding may quote an HTML, XML, or JSX comment out of the diff,
and two findings differing only inside such a quote collapsed to one key. Only
this tool's own markers are stripped now.

The authorship filter read Status.CRAuthoredFinding, which is also set when this
identity merely replied to a thread someone else opened. The opening comment's
own author and marker are tested instead, so a human's thread cannot carry their
text in as a finding of ours and suppress a real one.

ExistingThread.Resolved is dropped. Resolution is deliberately not part of the
decision, so a field nothing branches on only invited a caller to think it was.

@monit-reviewer monit-reviewer left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated PR Review

Reviewed commit: d762a66842a8
Profile: claude-monit-reviewer - Posting as: monit-reviewer

Summary

Reviewer Findings
go:implementation-tests 0
structure:repo-health 0
architecture:solid-reviewer-agnostic 0
structure:harness-engineering 0

Reviewer Coverage

  • go:implementation-tests — complete (constrained); skipped: none; constraints: none
  • structure:repo-health — complete (constrained); skipped: none; constraints: none
  • architecture:solid-reviewer-agnostic — complete (constrained); inspected 2 assigned files (4 inspected across reviewers): internal/pipeline/pipeline.go, internal/reviewplan/reviewplan.go; skipped: none; constraints: Scope limited to the two assigned files; the two new test files were read as context only. Verified by reproduction in a scratch copy: go build ./..., go vet and gofmt clean, internal/reviewplan and internal/threadcontext suites pass, internal/pipeline TestExistingFindingThreads* pass. The full internal/pipeline suite was not run to completion (exceeds the command timeout in this envir... Wrote an end-to-end probe (Build -> posted body with a real action marker prepended -> threadcontext.Normalize -> existingFindingThreads -> Build) covering inline, file-level fallback, a body quoting a non-marker HTML comment, and a body containing marker text; all four suppress the repeat.
  • structure:harness-engineering — complete (constrained); inspected 2 assigned files (4 inspected across reviewers): internal/pipeline/pipeline.go, internal/reviewplan/reviewplan.go; skipped: none; constraints: none
Inspected files (4)
  • internal/pipeline/existing_threads_test.go
  • internal/pipeline/pipeline.go
  • internal/reviewplan/dedupe_raised_test.go
  • internal/reviewplan/reviewplan.go

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 5m 34s | ~$6.23 (est.) | claude-sonnet-5, claude-opus-5 | cr 0.10.294
Field Value
Model claude-sonnet-5, claude-opus-5
Reviewers go:implementation-tests, structure:repo-health, architecture:solid-reviewer-agnostic, structure:harness-engineering
Engine claude_cli · claude-sonnet-5, claude-opus-5
Reviewed by cr · monit-reviewer
Duration 5m 34s wall · 10m 27s compute
Cost ~$6.23 (est.)
Tokens 92 in / 20.6k out

Per-workstream usage

  • go:implementation-tests — claude-sonnet-5
    • In: 32
    • Out: 4.7k
    • Cache read: 1.7M
    • Cache create: 211.5k
    • Cost: ~$1.39 (est.)
    • Duration: 3m 32s
  • structure:repo-health — claude-sonnet-5
    • In: 10
    • Out: 1.9k
    • Cache read: 392.5k
    • Cache create: 220.4k
    • Cost: ~$0.97 (est.)
    • Duration: 49s
  • architecture:solid-reviewer-agnostic — claude-opus-5
    • In: 24
    • Out: 10.7k
    • Cache read: 1.3M
    • Cache create: 228.9k
    • Cost: ~$2.36 (est.)
    • Duration: 4m 54s
  • structure:harness-engineering — claude-sonnet-5
    • In: 20
    • Out: 3.0k
    • Cache read: 848.5k
    • Cache create: 176.0k
    • Cost: ~$0.96 (est.)
    • Duration: 56s
  • orchestrator-rollup — claude-sonnet-5
    • In: 6
    • Out: 349
    • Cache read: 134.2k
    • Cache create: 136.2k
    • Cost: ~$0.56 (est.)
    • Duration: 14s

@piekstra
piekstra marked this pull request as ready for review August 31, 2026 21:20
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.

2 participants