Skip to content

feat(review): re-check a maintainer's decline against the code - #453

Merged
devops-thiago merged 3 commits into
release/v0.6.0from
feat/169-recheck-declines
Aug 8, 2026
Merged

feat(review): re-check a maintainer's decline against the code#453
devops-thiago merged 3 commits into
release/v0.6.0from
feat/169-recheck-declines

Conversation

@devops-thiago

@devops-thiago devops-thiago commented Aug 8, 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 maintainer replies to decline a finding, the follow-up analysis recorded it
justified and the bot moved on — it never checked whether the stated rebuttal
actually holds. A dismissal was treated as ground truth rather than a claim, so a
correct finding could be closed by an incorrect rebuttal, and the rebuttal
often names the very mechanism that makes the bug real.

This PR makes a decline a claim to verify. Two layers, both deliberately conservative:

1. Prompt rule (PrReviewPrompts.SYSTEM, previous_findings_status contract).
Before marking a prior finding justified, trace the reply's stated reason against
the code in the provided material. When that material plainly contradicts the
premise, keep the finding unresolved and quote the contradicting line in the note —
never re-raise it as a new finding. The rule also spells out the conservative side:
override only at high confidence, on evidence quotable from the provided material;
style, intent, accepted risk, priority, or any premise whose supporting code is not in
context keep the decline.

2. Deterministic post-processing (FollowUpAnalyzer.recheckDeclines +
RebuttalContradiction), in the same shape as the existing supersedeVanished /
addUnreportedVanished status rewriters, so the guarantee does not rest on the model
obeying prose. It detects exactly one high-precision family — the dogfood one — and
all three legs must hold:

  1. the prior finding is about concurrency (race, check-then-act, thread-safety, atomicity);
  2. the maintainer's reply asserts concurrency is impossible ("single-threaded", "runs
    serially", "only ever called from …"), judged on the reply with fenced blocks and
    blockquotes stripped, so quoted material is never read as the maintainer's assertion;
  3. the reviewed diff contains a concurrent-dispatch construct (newVirtualThreadPerTaskExecutor,
    newCachedThreadPool, executor.submit/execute, CompletableFuture.runAsync,
    new Thread(...), @Async, parallelStream()).

The status is then rewritten justifiedunresolved with a one-line note quoting both
the claim and the contradicting line. Anything else — every rebuttal about style, intent,
accepted risk, or priority, and every premise not refutable from code text — matches nothing
and keeps the decline.

Safety properties:

  • One push-back, then defer. The re-check only fires while the thread carries a single
    maintainer reply. A second reply is the maintainer answering the push-back and always wins,
    so the bot can never keep re-opening the same finding round after round.
  • The re-opened finding re-enters the ordinary unresolved path: it holds approval
    (APPROVECOMMENT) exactly like any other unresolved prior finding and is never
    re-posted as a new inline comment, so nobody answers the same comment twice.
  • REVIEW_DECLINE_RECHECK_ENABLED=false disables the step outright, making a maintainer
    reply final.

This is also the gate that makes durable maintainer-feedback memory safe: only declines that
survive this re-check are sound enough to persist as learnings.

Known limitation (stated honestly). The deterministic step can only refute a rebuttal whose
contradicting code is inside the material the review call saw. In the dogfood PR the executor
producer itself was an unchanged file and would not have been visible — but the same PR's
CommentCommandService change was in the diff and contains executor.execute(() -> execute(ctx)),
which is the evidence the regression test uses. When the mechanism lives entirely outside the
diff, only the prompt rule can catch it, and only when the model has that context.

Files

File Change
review/RebuttalContradiction.java New. Deterministic claim/evidence matcher; returns a quoted claim + quoted code line, or nothing.
review/FollowUpAnalyzer.java New recheckDeclines(...) status rewriter, plus the thread/reply lookup and the enabled flag.
review/VerdictBuilder.java Wires the re-check into build(...) after addUnreportedVanished; lazily supplies the reviewed diff (budget batches, else ctx.diff()).
review/ai/PrReviewPrompts.java Prompt rule in the previous_findings_status contract.
config/ThrillhouseConfig.java, application.properties New thrillhousebot.review.decline-recheck-enabled (default true).
README.md, .env.example Config table row, .env.example entry, and a "Re-checking declines" section.

Related Issues

Fixes #169

How Has This Been Tested?

New tests: RebuttalContradictionTest (9 cases), 5 new cases in FollowUpAnalyzerTest, and an
end-to-end wiring case in VerdictBuilderTest. Both directions are covered, and each was
validated red/green by neutralizing only the production code.

(a) A code-contradicted rebuttal must not be recorded justified. Neutralized by early-returning
the statuses unchanged from recheckDeclines and returning Optional.empty() from
RebuttalContradiction.find:

FollowUpAnalyzerTest.recheckShouldReopenDeclineWhoseAsyncAfterAckPremiseTheReviewedCodeContradicts
  org.opentest4j.AssertionFailedError: a decline the reviewed code contradicts must not be
  recorded justified ==> expected: "unresolved" but was: "justified"

RebuttalContradictionTest.shouldContradictAsyncAfterAckRebuttalWhenTheCodeDispatchesConcurrently
  org.opentest4j.AssertionFailedError: the async-after-ack rebuttal is refuted by
  executor.execute(...) in the reviewed code ==> expected: "true" but was: "false"

(b) A style/intent rebuttal must still be recorded justified. Neutralized in the other
direction, by widening the claim pattern so the re-check over-fires:

FollowUpAnalyzerTest.recheckShouldKeepDeclineThatRestsOnStyleOrIntent
  org.opentest4j.AssertionFailedError: a rebuttal that is not refutable from the code must be
  respected ==> expected: "justified" but was: "unresolved"

RebuttalContradictionTest.shouldRespectRebuttalsThatAreNotRefutableFromCode (x4)
  org.opentest4j.AssertionFailedError: style / intent / accepted-risk rebuttals must keep the
  decline ==> expected: "true" but was: "false"

RebuttalContradictionTest.shouldIgnoreClaimsThatAppearOnlyInQuotedMarkdown
  org.opentest4j.AssertionFailedError: a blockquote or fenced block is quoted material, not the
  maintainer's own assertion ==> expected: "true" but was: "false"

(c) The escape hatch and the config flag. Neutralized by relaxing the single-reply guard to
humanReplies.isEmpty() and by dropping the flag check:

FollowUpAnalyzerTest.recheckShouldDeferOnceTheMaintainerHasAnsweredTwice
  org.opentest4j.AssertionFailedError: a second maintainer reply answers the push-back and always
  wins ==> expected: "justified" but was: "unresolved"

FollowUpAnalyzerTest.recheckShouldBeDisabledByConfig
  org.opentest4j.AssertionFailedError: expected: "justified" but was: "unresolved"

(d) The wiring. Removing the recheckDeclines call from VerdictBuilder.build:

VerdictBuilderTest.declinedPriorFindingTheReviewedCodeContradictsStaysOpenAndHoldsApprove
  org.opentest4j.AssertionFailedError: expected: "1" but was: "0"

(The assertion values above are quoted for markdown's sake; the runner prints them in angle
brackets.) Every one passes again with the production code restored. The regression case is
derived from the dogfood scenario: the PrPauseService.pause() race finding, declined with
"only ever called from the /pause command path, which runs asynchronously on the review executor
after the webhook has returned 200", against a diff containing executor.execute(() -> execute(ctx));.

Format / lint / suite:

  • ./mvnw -B spotless:apply — clean

  • ./mvnw -B clean compile spotbugs:check spotless:check — BUILD SUCCESS, BugInstance size is 0

  • ./mvnw -B clean testTests run: 1892, Failures: 0, Errors: 0, Skipped: 0

  • Unit tests

  • Integration tests

  • Manual testing

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

Additional Notes

  • Why both a prompt rule and a deterministic step. The prompt rule generalizes (it can weigh
    any premise the model can trace, including ones no regex will ever encode) but is unverifiable
    and unenforceable. The deterministic step covers one narrow family with genuinely load-bearing
    tests and, crucially, runs after the model — so it also catches the case where the model itself
    accepted the bad rebuttal, which is exactly what happened in the dogfood PR. Neither alone is
    sufficient.
  • Only the concurrency family is implemented deterministically. "The caller already guards X" is in
    the prompt rule but not the deterministic step on purpose: proving a guard is absent from a
    partial diff is inference from missing evidence, which is the unsafe direction here.
  • The contradiction note lands on PreviousFindingStatus.note, which is persisted and shown in the
    dashboard but not yet rendered in the summary markdown — the same as the existing superseded note.
    Surfacing notes in the summary table felt like separate scope.
  • Issue feat(review): remember maintainer feedback across reviews (learnings) #38 (durable maintainer-feedback memory) is deliberately not implemented here.

A reply that declines a finding was treated as ground truth: the follow-up
analysis recorded it "justified" and the bot moved on, so a correct finding
could be closed by an incorrect rebuttal — and the rebuttal often names the
very mechanism that makes the bug real ("it only runs after the webhook is
acked, so there is no race", on an executor that starts a thread per event).

A decline is now a claim to verify. Two layers, both conservative:

- A prompt rule in the previous_findings_status contract tells the model to
  trace a decline's stated reason against the code in the provided material and
  keep the finding "unresolved", quoting the contradiction, when that material
  plainly refutes the premise — overriding only at high confidence, and
  respecting style, intent and accepted-risk rebuttals.
- A deterministic post-processing step re-checks the model's own verdict, in
  the same shape as supersedeVanished/addUnreportedVanished. It fires on one
  high-precision family: a concurrency finding, declined on a "this cannot run
  concurrently" premise, while the reviewed diff shows the path handed to a
  shared executor, a new thread, or an async dispatch. The status goes back to
  "unresolved" with a note quoting both the claim and the contradicting line;
  the finding is never re-posted as a new comment.

The re-check only fires while the thread carries a single maintainer reply, so
replying again always ends it and the bot cannot re-open the same finding round
after round. Everything not refutable from code text keeps the decline, and
thrillhousebot.review.decline-recheck-enabled=false disables the step outright.

This is also the gate that makes durable maintainer-feedback memory safe: only
declines that survive the re-check are sound enough to remember.

Refs #169
@github-actions

github-actions Bot commented Aug 8, 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 8, 2026

Copy link
Copy Markdown

🤖 ThrillhouseBot PR Summary

What this PR does

Adds a step after the AI review to re-check a maintainer's decline against the reviewed code, reopening the finding if the code plainly contradicts the rebuttal; the deterministic check currently covers only concurrency-related premises.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
  A["VerdictBuilder.build()"] --> B["Compute effective statuses\n(supersede/unreported vanished)"]
  B --> C["Call followUpAnalyzer.recheckDeclines()"]
  C --> D{"declineRecheckEnabled\n&& justified status exists?"}
  D -- No --> E["Return statuses unchanged"]
  D -- Yes --> F["Fetch reviewed code text\n(budget batches or ctx.diff())"]
  F --> G["For each prior status"]
  G --> H{"Status is justified?"}
  H -- No --> I["Keep status"]
  H -- Yes --> J["Find thread root comment\nand human replies"]
  J --> K{"Exactly one human reply?"}
  K -- No --> I
  K -- Yes --> L["RebuttalContradiction.find()"]
  L --> M{"Contradiction found?"}
  M -- No --> I
  M -- Yes --> N["Rewrite status to 'unresolved'\nwith contradiction note"]
  N --> O["Add to rewritten list"]
  O --> G
  E --> P["Merge into final effectiveStatuses"]
  I --> O
  P --> Q["Build effective response and verdict"]
Loading

Changes Overview

  • Files changed: 11
  • Lines added: +841
  • Lines removed: -1

Changed Files

File Change Summary
.env.example Modified Documents the new REVIEW_DECLINE_RECHECK_ENABLED variable with comment and default.
README.md Modified Adds config table row and a new 'Re-checking declines' section explaining the feature and its conservatism.
src/main/java/dev/thiagogonzaga/thrillhousebot/config/ThrillhouseConfig.java Modified Adds declineRecheckEnabled boolean config property with default true.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/FollowUpAnalyzer.java Modified Adds recheckDeclines method and wiring; checks for a single maintainer reply and calls RebuttalContradiction, guarded by a config flag.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/RebuttalContradiction.java Added New utility that detects when a maintainer's concurrency rebuttal is contradicted by concurrent-dispatch code in the reviewed diff.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/VerdictBuilder.java Modified Wires recheckDeclines into the build pipeline after supersede/unreported-vanished steps, supplying the reviewed diff text.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/PrReviewPrompts.java Modified Extends the previous_findings_status prompt rules to treat a decline as a verifiable claim and to override only on plain contradiction.
src/main/resources/application.properties Modified Maps REVIEW_DECLINE_RECHECK_ENABLED environment variable to thrillhousebot.review.decline-recheck-enabled.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/FollowUpAnalyzerTest.java Modified New tests for recheckDeclines covering contradicting rebuttal, non-refutable style/intent, double-reply defer, config flag, and edge cases.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/RebuttalContradictionTest.java Added Unit tests for RebuttalContradiction confirming the three-leg detection and exclusion of non-concurrency findings and quoted material.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/VerdictBuilderTest.java Modified End-to-end wiring test verifying that a contradicted decline reopens and holds approve, using a real FollowUpAnalyzer.

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
frontend check-run ⏳ Pending -
changes check-run ⏳ Pending -
trivy check-run ⏳ Pending -
test check-run ⏳ Pending -
actionlint check-run ⏳ Pending -
format check-run ⏳ Pending -
dependency-review check-run ⏳ Pending -
build check-run ⏳ Pending -

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

@thrillhousebot thrillhousebot Bot added documentation Improvements or additions to documentation enhancement New feature or request testing Test coverage and test quality labels Aug 8, 2026
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

codecov/patch flagged 24 uncovered lines and partial branches across the
decline re-check. The gaps were real, and two of them were dead code rather
than missing tests.

Production simplifications that remove unreachable branches:
- clip() used charAt(0) behind an isEmpty() guard that no caller could ever
  trigger; startsWith() needs no guard at all.
- assertedText() terminated every kept line with a newline, so the forward
  sentence walk could never reach end-of-text and its bound was unreachable.
  Joining the lines instead leaves an unterminated reply unterminated, which
  is both truer to the reply and a live case.
- recheckDeclines() dropped the previous.isEmpty() and inlineComments.isEmpty()
  fast paths: the id-range check and the thread lookup already return "no
  contradiction" for both, so they were branches with no behavior behind them.
  The decline test moved into a named hasDecline() helper.

New tests, all covering real behavior:
- every no-op input the re-check can be handed, as one parameterized case per
  guard, each asserting the statuses come back untouched;
- a mixed status list, so only the declined entry is rewritten;
- bot, author-less, body-less and other-thread replies do not count as the
  maintainer answering the push-back;
- the injected constructor honouring the config key;
- title-only and description-only findings, sentence-boundary quoting on every
  terminator, evidence on an unterminated last line, and a -/+ diff marker;
- the legacy ctx.diff() path, for a non-budgeted plan and for a budgeted plan
  whose batches are all empty.

All three changed files are now free of uncovered lines and partial branches.

Refs #169

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

Sonar flagged both matchers under java:S5843 — regex complexity 53 and 35
against an allowance of 20. Worth taking seriously rather than waving through:
these patterns run over untrusted maintainer reply prose, and this is the code
that decides when the bot may overrule a human. A single mega-alternation that
intricate is hard to convince yourself fires only on genuine "concurrency is
impossible" claims, and the override is only sound if it is high-confidence.

Each alternation is now several small patterns, one per argument or construct,
matched through a shared earliestMatch helper:
- serialization claims: single-threaded / runs serially / cannot run
  concurrently / never runs concurrently / no race / only ever called from.
- concurrent dispatch: pooled executor factory / a fixed pool wider than one
  thread / handing work to an executor / an async future / a raw thread,
  @async or a parallel stream.

The matched set is unchanged except for two deliberate merges of alternatives
that already subsumed one another ("only ever called from" now covers the bare
"only called from"; "no race" covers "there is no race"), so nothing new
matches. earliestMatch takes the leftmost match across every pattern, which is
exactly what one alternation did, so splitting cannot change which sentence is
quoted back at the maintainer — pinned by a test that fails under a
first-pattern-wins implementation.

Hardening the same untrusted-input surface while here:
- every quantifier is bounded (\s{0,16} / \s{1,16}), and the fenced-block body
  is capped, so an unclosed fence cannot make the strip rescan from every
  opener.
- replies over 20k characters are not analyzed at all. Skipping is the
  conservative outcome: an unread reply keeps its decline.

Also switches the four Mockito calls to static imports, matching every
neighbouring test in the package (java:S8924).

Refs #169

@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 8, 2026

Copy link
Copy Markdown

@devops-thiago
devops-thiago merged commit 7ad53e5 into release/v0.6.0 Aug 8, 2026
16 checks passed
@devops-thiago
devops-thiago deleted the feat/169-recheck-declines branch August 8, 2026 11:12
devops-thiago added a commit that referenced this pull request Aug 8, 2026
Absorbs #449 (per-repo ignore patterns), #451 (whole-change-set PR summary),
#453 (decline re-check) and four dependency bumps.

Two textual conflicts, both from independent additions at the same insertion
point rather than any disagreement:

- ReviewContextLoader: #449's resolveIgnoreGlobs and this branch's
  resolveConfigKeyContext are separate private helpers that git could not
  place. Kept both.
- FindingPipelineTest: #451 parameterized the reviewContext helper with an
  explicit reviewable-file list while this branch added the configKeyContext
  record component. Kept both — the helper's parameter, with "" in the new
  component's position.

One silent breakage git merged cleanly: #453's new declinedRaceContext helper
constructs a ReviewContext without configKeyContext. Filled in.

The interaction between the two features is the one worth noting. #449 made
load() compute reviewableFiles from the global globs unioned with the repo's
own, and config-key resolution already read that post-filter list, so a key
documented only in an ignored file is now correctly never resolved — and it
inherits per-repo ignore rules for free. Pinned with a test that fails if the
raw file list is ever passed instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request testing Test coverage and test quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant