Skip to content

fix(review): keep real findings in the previous-findings context across a zero-finding round - #470

Merged
devops-thiago merged 2 commits into
release/v0.6.0from
fix/455-previous-findings-context
Aug 9, 2026
Merged

fix(review): keep real findings in the previous-findings context across a zero-finding round#470
devops-thiago merged 2 commits into
release/v0.6.0from
fix/455-previous-findings-context

Conversation

@devops-thiago

@devops-thiago devops-thiago commented Aug 9, 2026

Copy link
Copy Markdown
Owner

What type of PR is this?

  • 🐛 Bug fix
  • ✨ Feature
  • 📝 Documentation
  • 🔧 Refactor
  • 🚀 Performance
  • ✅ Test
  • 🔒 Security
  • 📦 Dependency update
  • 🏗️ CI/CD

Description

When a follow-up review round produced zero findings, the next round's ## Previous Review Findings section was filled with the previous bot review's body text verbatim — including the bot's own "No new issues in this revision, but N previous finding(s) remain unresolved …" boilerplate — while the real prior finding vanished from tracking. The unresolved count then drifted upward every round and VerdictBuilder demoted APPROVE → COMMENT indefinitely.

Three defects, all in the context-construction path:

  1. The review-body fallback fired on the wrong condition. FollowUpAnalyzer.buildPreviousFindingsContext(...) selected it whenever the structured rendering came out empty, which is true both when there is genuinely no persisted AI response (its documented purpose) and when a persisted round legitimately found nothing. The caller now passes that fact explicitly (previousResponsePersisted) instead of it being inferred from the shape of the output.

  2. The fallback offered the bot's own prose as findings. Even in the legitimate no-persisted-response case, a review body the bot generated about its own verdict carries no finding, so presenting it under "the following issues were flagged … determine if it is resolved, unresolved, or justified" is a category error. The fallback now discards a body it recognizes as self-authored, matched against the producers' own constants (the unresolved-previous sentence, the clean-review message, the two CI-hold notices, the partial-review banner) so the recognizer cannot drift from the text it recognizes. A body it did not generate is still passed through.

  3. The open set was re-derived from the latest round rather than carried. A zero-finding round exposes no ids, so treating it as "the previous round" evicted the still-open finding from the prompt, from previous_findings_status, and from every id-keyed consumer. The prior round a review reports on is now the newest persisted round that actually raised findings, and the deterministic backstop pairs each round's previous_findings_status with that same round instead of blindly with the round before it — which is why the count could never come back down (the ids never mapped, so nothing ever closed).

Depth chosen. The issue's suggested fix (3) — carry-forward — is implemented in the form that does not destabilise the id space the #169 decline re-check depends on: the effective previous round's list is carried whole, so every id stays exactly the 1-based position the finding had when it was posted, which is the index its inline comment's hidden thrillhousebot:finding=N marker carries. Filtering closed findings out of the carried list (the other reading of "carry forward") would renumber the survivors and silently break marker-based thread matching, so it is deliberately not done. Accumulating findings across rounds that each raised some remains the backstop's job, unchanged.

Files

  • FollowUpAnalyzer.java — explicit previousResponsePersisted flag on the context builder; effectivePreviousFindings / effectivePreviousRoundIndex (skip zero-finding rounds); isPersistedResponse (tells a parsed empty round from the blank stand-in without a second parse); isSelfAuthoredStatusBody guard on the review-body fallback; backstop replay now pairs a status block with the newest earlier round that raised findings.
  • ReviewContextLoader.java — resolves that round once, and derives the rendered context, the id space (previousFindingsList()), the raw JSON the supersede pass re-reads, and the older-rounds slice from it, so the three cannot drift apart.
  • ReviewResult.javaisUnresolvedPreviousMessage(...) plus shared lead-in constants for the CI-hold and partial-review bodies, so the guard above matches text the producers own.
  • ReviewPublisher.javanoIssuesBody now builds from those shared constants (text unchanged, byte for byte).

Related Issues

Fixes #455

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

Eleven new tests, each validated red → green. Because the tests reference the new API, plain git stash push -- src/main/java yields a compile failure rather than an assertion; the production change was therefore neutralized behaviourally instead — the four changed decisions reverted to their old form with the new signatures kept — so every test fails on its assertion. Verbatim red-phase output (? is the console rendering the em dash / emoji):

FollowUpAnalyzerTest.previousFindingsContextShouldNeverCarryTheBotsOwnStatusBody:1837 a persisted round that legitimately found nothing must render no previous findings ==> expected: <> but was: <No new issues in this revision, but 1 previous finding(s) remain unresolved ? fix them, or reply on their review thread (where one exists) with why they are deferred.>
FollowUpAnalyzerTest.reviewBodyFallbackShouldDropEveryBotGeneratedBodyAndKeepTheRest:1853 expected: <> but was: <Everything's coming up Thrillhouse! ?

No issues found in this PR.>
ReviewContextLoaderTest.findingFromRoundOneSurvivesAZeroFindingRoundNumberedAndInTheIdSpace the zero-finding round evicted the still-open finding from the id space ==> expected: <1> but was: <0>
ReviewContextLoaderTest.zeroFindingRoundWithNothingToCarryLeavesTheIdSpaceEmptyAndTheSectionAbsent an absent previous-findings section is what suppresses the prompt block entirely ==> expected: <> but was: <No new issues in this revision, but 1 previous finding(s) remain unresolved ? fix them, or reply on their review thread (where one exists) with why they are deferred.>
ReviewContextLoaderTest.carriedFindingsKeepTheIdsTheirOwnRoundGaveThem expected: <{2=.../RepoSettingsParser.java, 1=.../RepoSettingsResolver.java}> but was: <{}>
VerdictBuilderTest.unresolvedCountAcrossAZeroFindingRoundStaysAtTheOneRealFinding:925 the unresolved count must equal the number of distinct real findings still open ==> expected: <1> but was: <2>
VerdictBuilderTest.resolvedPriorFindingNoLongerPhantomHoldsApproveAfterAZeroFindingRound:949 expected: <0> but was: <1>

Restoring the production change turns all seven green.

Four further tests landed in a763f28 to close the codecov/patch gap, covering the absent-input branches of the new helpers (plus one added assertion on the existing reviewBodyFallback… test). Seven mutations in total, each neutralizing one guard or conjunct in the production method and nothing else:

dropped `if (priorAiResponses == null) return -1;`
FollowUpAnalyzerTest.effectivePreviousRoundHelpersShouldTreatAbsentRoundsAsNoPreviousRound:1893 ? NullPointer Cannot invoke "java.util.List.size()" because "priorAiResponses" is null

dropped `response != null &&` from the round-selection loop
FollowUpAnalyzerTest.effectivePreviousRoundHelpersShouldTreatAbsentRoundsAsNoPreviousRound:1901 ? NullPointer Cannot invoke "dev.thiagogonzaga.thrillhousebot.review.ai.ReviewResponse.findings()" because "response" is null

dropped `response != null &&` from isPersistedResponse
FollowUpAnalyzerTest.isPersistedResponseShouldSeparateAStoredRoundFromAMissingOne:1914 expected: <false> but was: <true>

isPersistedResponse: reference identity `response != EMPTY_RESPONSE` weakened to `!response.equals(EMPTY_RESPONSE)`
FollowUpAnalyzerTest.isPersistedResponseShouldSeparateAStoredRoundFromAMissingOne:1920 a round that legitimately found nothing did persist a response ==> expected: <true> but was: <false>

dropped `if (text == null) return false;` from isUnresolvedPreviousMessage
ReviewResultTest.isUnresolvedPreviousMessageShouldRejectTextThatOnlyResemblesIt:215 ? NullPointer Cannot invoke "String.strip()" because "text" is null

dropped `&& stripped.endsWith(UNRESOLVED_PREVIOUS_SUFFIX)`
ReviewResultTest.isUnresolvedPreviousMessageShouldRejectTextThatOnlyResemblesIt:216 a human review opening with the same words carries a real finding and must be kept ==> expected: <false> but was: <true>
FollowUpAnalyzerTest.reviewBodyFallbackShouldDropEveryBotGeneratedBodyAndKeepTheRest:1879 a body that only opens like the generated sentence carries a real finding and is kept ==> expected: <No new issues in this revision, but the null check on line 12 is still wrong.> but was: <>

dropped `.strip()` from isUnresolvedPreviousMessage
ReviewResultTest.isUnresolvedPreviousMessageShouldMatchTheGeneratedSentenceForAnyCount:202 a stored review body keeps its surrounding whitespace ==> expected: <true> but was: <false>

The equals() mutation is the one worth keeping pinned: it is the obvious-looking simplification, and it silently puts the review-body fallback back into the exact path this PR removes it from, because a round that legitimately found nothing is equal to the blank stand-in without being the same object.

The three regression tests the issue names:

  • Round N raises a finding, round N+1 raises none → round N+2 still carries it, numbered, count stays at 1ReviewContextLoaderTest.findingFromRoundOneSurvivesAZeroFindingRoundNumberedAndInTheIdSpace (drives the real load(...) with the real FollowUpAnalyzer, asserting the section actually handed to the model) plus VerdictBuilderTest.unresolvedCountAcrossAZeroFindingRoundStaysAtTheOneRealFinding.
  • A body matching the unresolvedPreviousMessage shape never appears in {{previousFindings}}FollowUpAnalyzerTest.previousFindingsContextShouldNeverCarryTheBotsOwnStatusBody, covering both the zero-finding path and the legitimate fallback path; reviewBodyFallbackShouldDropEveryBotGeneratedBodyAndKeepTheRest extends it to every body the bot generates and pins that a body it did not generate is preserved.
  • A PR whose only prior finding was resolved returns to APPROVEVerdictBuilderTest.resolvedPriorFindingNoLongerPhantomHoldsApproveAfterAZeroFindingRound.

The two #169 pins:

  • ReviewContextLoaderTest.zeroFindingRoundWithNothingToCarryLeavesTheIdSpaceEmptyAndTheSectionAbsent — with nothing to carry, previous stays empty and no pseudo-finding fabricated from a review body ever enters the id space recheckDeclines uses.
  • ReviewContextLoaderTest.carriedFindingsKeepTheIdsTheirOwnRoundGaveThem — a real prior finding keeps the id its own round gave it across a zero-finding round, so previous_findings_status id 2 still names the same finding it always did.

Build:

  • ./mvnw -B spotless:apply — clean
  • ./mvnw -B clean compile spotbugs:check spotless:checkBugInstance size is 0, BUILD SUCCESS
  • ./mvnw -B clean test — 2325 tests, 0 failures, 0 errors

Coverage: codecov/patch went 91.49% → 100% (47/47 lines, 0 misses, 0 partials). What was uncovered was the absent-input handling on the three new helpers — the null list and null slot guards in effectivePreviousRoundIndex, the null guard in isPersistedResponse, and the null guard plus the endsWith half of isUnresolvedPreviousMessage. All are now covered by the four tests above. No codecov.yml, pom.xml, or workflow file was changed.

Checklist

  • My code follows the project's coding standards
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly
  • My changes generate no new warnings or errors

Screenshots / Logs

Round 3 of PR #449, before this change (verbatim from the deployed app log):

## Previous Review Findings
The following issues were flagged in the previous review.
For each, determine if it is resolved, unresolved, or justified.
No new issues in this revision, but 1 previous finding(s) remain unresolved — fix them, or reply on their review thread (where one exists) with why they are deferred.

After this change the same round renders round 1's real finding, numbered 1., with its original id.

Additional Notes

  • No new configuration keys, so nothing to document in README.md.
  • The user-facing review text is unchanged: the CI-hold and partial-review sentences moved into shared constants byte for byte, and existing assertions on them still pass.
  • Deliberately out of scope, as the issue directs: the latent Set.copyOf(plan.omittedFiles()).contains(file.filename()) NPE at FindingPipeline.java:563. It is untouched and still open.
  • Also deliberately not attempted: accumulating open findings across rounds that each raised findings. That case is already covered by the deterministic backstop, and widening the numbered prompt list to span rounds would renumber findings away from their inline-comment markers.
  • A judgement call worth disagreeing with, if you do. The absent-input guards the four coverage tests pin are structurally unreachable through the production path as it stands: parsePreviousResponses ends in List.copyOf, and ReviewContext's compact constructor does too, so neither a null list nor a null element can actually reach effectivePreviousRoundIndex or isPersistedResponse today. I kept them and tested them rather than deleting them, on two grounds: they are public static methods rather than private helpers, and null-tolerance is the established convention of this class — parsePreviousResponses(null), toStatuses(null) and formatAnsweredEarlier(null) all behave the same way and are already tested. Each guard now carries a javadoc sentence stating the contract, so the tests pin documented behaviour rather than an accident. The alternative — deleting the guards and letting the callers' List.copyOf be the only defence — is a defensible reading, and it is a small change in each of the three spots if preferred.

…ss a zero-finding round

A follow-up round that found nothing evicted the still-open finding raised
before it and put the bot's own review body in its place: the fallback that
exists for sessions with no persisted AI response was selected whenever the
structured rendering came out empty, which is also true of a persisted round
that legitimately found nothing. The model was then shown the sentence
"No new issues in this revision, but N previous finding(s) remain unresolved"
under a header saying those were the issues flagged in the previous review,
the real finding lost its id, and because the backstop mapped each round's
previous_findings_status over the empty round, nothing ever closed and the
unresolved count climbed with every further round until APPROVE was
permanently demoted to COMMENT.

Three changes, all in the context-construction path:

- The caller now states whether a readable response was persisted instead of
  the analyzer inferring it from the shape of its own output, so the review
  body fallback fires only for the case its javadoc describes.
- That fallback discards a body it recognizes as one the bot generated about
  its own verdict, matched against the producers' own constants so the
  recognizer cannot drift from the text it recognizes. A body the bot did not
  write is still passed through.
- The prior round a review reports on is the newest persisted round that
  actually raised findings, and the approve backstop pairs a status block with
  that same round rather than blindly with the round before it. The round's
  list is carried whole, so every id stays the 1-based position the finding had
  when it was posted — the index its inline comment's marker carries and the id
  space previous_findings_status and the decline re-check both reference.

Refs #455
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@thrillhousebot

thrillhousebot Bot commented Aug 9, 2026

Copy link
Copy Markdown

🤖 ThrillhouseBot PR Summary

What this PR does

Prevents a zero-finding review round from evicting real prior findings and feeding the bot's own status body back to the model as a phantom finding, which inflated the unresolved count and blocked approval. It introduces effective-previous-finding logic that skips empty rounds, adds a persisted-response flag to avoid misusing the fallback, and detects and discards bot-generated status bodies.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
  A["ReviewContextLoader.load() computes previousRoundIndex / effectivePreviousFindings"]
  B["Calls buildPreviousFindingsContext(previousFindings, previousResponsePersisted, ...)"]
  C{"structured not empty or previousResponsePersisted?"}
  D["Return structured + answered"]
  E["Fallback: buildPreviousFindingsContext(priorReviews)"]
  F{"body null or isSelfAuthoredStatusBody?"}
  G["Return '' + answered"]
  H["Return body + answered"]
  A --> B
  B --> C
  C -- yes --> D
  C -- no --> E
  E --> F
  F -- yes --> G
  F -- no --> H
Loading

Changes Overview

  • Files changed: 8
  • Lines added: +562
  • Lines removed: -46

Changed Files

File Change Summary
src/main/java/dev/thiagogonzaga/thrillhousebot/review/FollowUpAnalyzer.java Modified Adds effectivePreviousFindings(), isPersistedResponse(), and isSelfAuthoredStatusBody(); updates openFindingsAcrossRounds to pair status with the last non-empty round.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewContextLoader.java Modified Switches to effectivePreviousFindings in load() and previousFindingsList(); passes a previousResponsePersisted flag to avoid misusing the review-body fallback.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPublisher.java Modified Uses the new shared constants for no-issues CI bodies instead of duplicated string literals.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewResult.java Modified Extracts shared constants for CI-hold/unreadable/truncation lead-ins and adds isUnresolvedPreviousMessage() for status-body detection.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/FollowUpAnalyzerTest.java Modified Verifies that bot-authored status bodies are never presented as previous findings, both for a zero-finding round and the fallback path.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewContextLoaderTest.java Modified Adds integration-level tests verifying that a finding from round 1 survives a zero-finding round, that carried findings keep their original ids, and that no phantom finding is created.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewOrchestratorTest.java Modified Updates mock signatures to match the new buildPreviousFindingsContext parameter list.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/VerdictBuilderTest.java Modified Validates that the unresolved count stays at 1 after a zero-finding round and that a resolved prior finding restores APPROVE status.

Risk Assessment

Risk Count
🔴 Critical 0
🟠 High 0
🟡 Medium 0
🔵 Low 0

No new issues found in this PR, but the review cannot be approved until CI is confirmed green.

⚠️ CI Checks Status

Some checks are still pending or have failed:

Check Type Status Detail
trivy check-run ⏳ Pending -
actionlint check-run ⏳ Pending -
test check-run ⏳ Pending -
format check-run ⏳ Pending -
changes check-run ⏳ Pending -
frontend check-run ⏳ Pending -
dependency-review check-run ⏳ Pending -

Automated review by ThrillhouseBot. Reply with /review to re-run.

@thrillhousebot thrillhousebot Bot added bug Something isn't working java Pull requests that update java code testing Test coverage and test quality labels Aug 9, 2026
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…elpers

codecov/patch flagged four uncovered branches in the round-selection
helpers added for #455: the absent-list and absent-slot guards in
effectivePreviousRoundIndex, the absent-response guard in
isPersistedResponse, and the second half of the
isUnresolvedPreviousMessage match.

Each is load-bearing rather than defensive noise. The helpers define the
id space every downstream consumer keys off, so absent input has to
degrade to "no previous round" instead of failing the review mid-flight,
the way parsePreviousResponses and toStatuses already treat it — the
javadoc now says so. isPersistedResponse discriminates by reference
identity, so the natural "simplification" to equals() would report a
round that legitimately found nothing as unpersisted and put the
review-body fallback back in the path the fix removed it from. And both
ends of isUnresolvedPreviousMessage are required because everything it
accepts is discarded: a human review body that merely opens with the same
words carries a real finding, and matching on the opening alone would
throw it away.

Refs #455

@thrillhousebot thrillhousebot Bot 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.

Everything's coming up Thrillhouse! 🎉

No issues found in this PR.

@sonarqubecloud

sonarqubecloud Bot commented Aug 9, 2026

Copy link
Copy Markdown

@devops-thiago
devops-thiago merged commit 974cd08 into release/v0.6.0 Aug 9, 2026
14 checks passed
@devops-thiago
devops-thiago deleted the fix/455-previous-findings-context branch August 9, 2026 03:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working java Pull requests that update java code testing Test coverage and test quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant