feat(review): /improve — whole-PR improvement pass (PR-Agent parity) - #452
Conversation
PR-Agent exposes /improve, a dedicated pass proposing broad, committable improvements across an entire PR. ThrillhouseBot only attached inline suggestion blocks to review findings, so there was no way to ask for an "improve this change set" pass — a gap evaluators comparing OSS options notice immediately. Add /improve (and the "@thrillhousebot improve" mention form) as an on-demand, write-gated command that reuses the existing on-request suggestion machinery: AbstractPrSuggestionGenerator loads the diff under the review line budget, the PR title/body, and the resolved repository instructions, and now also carries the head SHA and the ignore-filtered file list so a command can anchor its suggestions back onto the diff. Each improvement whose quoted code anchors cleanly is posted as an inline committable suggestion; the rest are surfaced as copy-paste blocks in the run's summary comment, which reuses the review path's partial-coverage disclosure when the line budget dropped whole files. The command is disabled while a PR is paused, respects the per-run comment cap, and ships behind thrillhousebot.review.improve-enabled (REVIEW_IMPROVE_ENABLED), defaulting on like add-docs-enabled since it never runs automatically and only spends budget when a write-access holder asks for it. Refs #316
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
🤖 ThrillhouseBot PR SummaryWhat this PR doesThis change adds an on-demand Control-Flow Diagram🔀 Show diagramflowchart TD
A["User comments /improve"] --> B["Detect command (TriggerDetector)"]
B --> C["CommentCommandService.handleImprove"]
C --> D{"improveEnabled?"}
D -->|No| E["Log & ignore"]
D -->|Yes| F{"Authorized?"}
F -->|No| G["Log & ignore"]
F -->|Yes| H{"PR paused?"}
H -->|Yes| I["Post paused notice"]
H -->|No| J["PrImprovementService.handle"]
J --> K["loadInputs: fetch diff, title, body, instructions"]
K --> L{"Diff available?"}
L -->|No| M["Post NO_CHANGES"]
L -->|Yes| N["improveAssistant.improve"]
N --> O{"Parse response"}
O -->|Failure| P["Post GENERATION_FAILED"]
O -->|Success| Q["Post inline suggestions / copy-paste blocks"]
Q --> R["Post summary comment with cap disclosure"]
R --> S["Done"]
Changes Overview
Changed Files
…and 1 more file(s). Risk Assessment
No new issues found in this PR, but the review cannot be approved until CI is confirmed green.
|
| Check | Type | Status | Detail |
|---|---|---|---|
| changes | check-run | ⏳ Pending | - |
| build | check-run | ⏳ Pending | - |
| test | check-run | ⏳ Pending | - |
| frontend | check-run | ⏳ Pending | - |
| trivy | check-run | ⏳ Pending | - |
| format | check-run | ⏳ Pending | - |
| actionlint | check-run | ⏳ Pending | - |
| dependency-review | check-run | ⏳ Pending | - |
Automated review by ThrillhouseBot. Reply with /review to re-run.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
codecov/patch flagged 33 uncovered lines across the new /improve code — all of them the fail-soft and degradation paths that matter most for a command that runs off the webhook ACK thread. Adds ImprovementParserTest and ImprovementResponseTest, extends SuggestionFormatterTest with the improvement renderers, and covers the remaining PrImprovementService branches: a PR with a blank or absent head ref, a formatter that yields no reviewable file list, a blank or absent rendered diff, a multi-line replacement whose range cannot be resolved, a reported line that only snaps to a neighbour, the per-run cap swallowing every improvement, and a summary post that throws. No production behaviour changes. Refs #316
The docs site's landing page keeps its own short command list, separate from the README sections the other pages include, so the README-only change left it enumerating every on-demand generation command except the new one. Refs #316
| " further improvement(s) were not posted because the per-run comment cap was reached" | ||
| + " — re-run `/improve` after addressing these."); | ||
| } | ||
| return sb.append(FOOTER).append(ReviewResult.truncationDisclosure(omittedFiles)).toString(); |
There was a problem hiding this comment.
🟡 MEDIUM — Lenient whitespace comparison may allow un-anchorable suggestions (medium confidence — verify before acting)
quotesCurrentLine uses .strip() on both the diff line text and the improvement's suggestion_old, stripping all whitespace including leading indentation. If the assistant omits leading whitespace in suggestion_old, the comparison will still match, causing the bot to post an inline suggestion whose old code does not match the actual source line exactly. GitHub may then fail to apply the suggestion, breaking the committable-suggestion flow. Compare after trimming only trailing whitespace (.stripTrailing()) to preserve leading indentation while tolerating insignificant trailing spaces.
| return sb.append(FOOTER).append(ReviewResult.truncationDisclosure(omittedFiles)).toString(); | |
| return current == null || current.stripTrailing().equals(improvement.suggestionOld().stripTrailing()); |
quotesCurrentLine compared the diff line and the model's suggestion_old with strip(), which discards leading whitespace. A model that quoted the code un-indented therefore still matched, and the improvement was posted as a committable suggestion — but committing one replaces the anchored line with suggestion_new verbatim, and a model that re-indented what it quoted has re-indented its replacement too. Applying it silently reflowed the line, which in an indentation-sensitive language changes what the code means. Compare with stripTrailing() so indentation is part of the match while invisible trailing whitespace still is not, and fail closed when the line text cannot be read: an unverifiable line must not be rewritten on the author's behalf. Both cases now degrade to the copy-paste block a human applies deliberately, which is the whole point of that fallback. Also renames SuggestionFormatter's CODE_FENCE_CLOSE to CODE_FENCE: the constant is a standalone fence line carrying a newline on each side and is used as the opening delimiter as well as the closing one, so the old name mis-described every call site. Value and rendered output are unchanged, now locked by byte-exact tests. Refs #316
Same expected bytes, verified by comparing the two String values rather than by the test still passing; a text block reads closer to the markdown it pins. Also tells the /improve prompt to copy suggestion_old's indentation character for character. The anchoring check now requires it, so without this the model's un-indented quotes would merely be downgraded to copy-paste notes instead of becoming one-click commits. Refs #316
/improve was built on the pre-#53 design: one model call over the diff string that ReviewDiffFormatter caps at max-diff-lines. For a whole-PR improvement pass that is the wrong failure mode — a large PR silently shrank to its first N lines, and whole files never reached the model at all. On a change set with a 4-line cap the model received literally "(diff truncated at 4 lines — 2 files omitted)" and no file content. Plan batches with DiffBudgetPlanner over the reviewable file list under the per-call token budget instead, the way the review path has worked since #53, and run one assistant call per batch. The line cap no longer gates coverage; it only shapes the string that is now unused for the model call. Details: - The shared prompt overhead is assembled from this command's own prompts, mirroring plan(reviewable, PromptInputs), so batches sized as in-budget do not overshoot the real input limit. - Results are merged across batches and deduped by file and line, so two batches can never propose the same line twice. - A batch whose call or parse fails is skipped rather than failing the run, and the count is disclosed; only an all-batches failure posts the failure notice. - Coverage disclosure now comes from the plan's omitted and clipped files, named rather than counted, via a new truncationDisclosure overload mirroring truncationNotice's detail variant. - max-input-tokens=0 keeps budgeting off as a single uncapped batch rather than regressing to the line-capped string. - Per-repo ignore patterns (#449) are applied on top of the global set. While the pass stopped at max-diff-lines, an ignored file beyond the cap was excluded by accident; now that every file is in scope it has to be excluded on purpose, or /improve would propose committable edits to code a repository asked the bot to leave alone. Anchoring is unchanged and still resolves against the whole PR's line map, so a suggestion from any batch anchors to its correct absolute line. Refs #316
There was a problem hiding this comment.
ThrillhouseBot noted 1 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):
- LOW: Verify that file list retrieval paginates to avoid silent truncation (
src/main/java/dev/thiagogonzaga/thrillhousebot/review/AbstractPrSuggestionGenerator.java:84)
The new/improvecommand usesSoftLoaders.filesto retrieve the PR file list, which is then used for diff rendering and line anchoring. GitHub's REST API defaults to 30 files per page; if the existingSoftLoaders.filesdoes not implement pagination, a PR with more than 30 files will have all improvements for files beyond the first page silently dropped because their diffs and line mappings will be missing. Verify thatSoftLoaders.files(not shown in this diff) walks all pages or that the bot explicitly limits the scope to a single page with justification.
With batching, max-ai-calls rather than max-diff-lines is what bounds coverage on a large PR. Covering only the first N batches and saying nothing would be the same class of defect as the line cap this replaced, so the files that never got a batch are named in the summary. Refs #316
There was a problem hiding this comment.
ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:
- Check build is pending
- Check changes is pending
- Check test is pending
- Check format is pending
- Check trivy is pending
- Check actionlint is pending
- Check frontend is pending
- Check dependency-review is pending
Additionally, 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.
The batches were planned over the per-repo-ignore-filtered files (#449), but the DiffLineResolver was still built from inputs.reviewableFiles() — the list from before those globs were applied. The two disagreed about what is in scope, so an improvement naming an ignored file still resolved against it and was posted as a committable, one-click suggestion against code the repository asked the bot to leave alone. The model never sees an ignored file, but it can invent a path, and a verbatim-commit surface must not be reachable that way. Resolve the effective file list once in handle() and thread it into both the planner and the resolver, the way ReviewContextLoader computes its ignore set once. The map still spans the whole PR rather than one batch, so a suggestion produced by any batch keeps anchoring to its correct absolute line; a suggestion naming an ignored file now fails to anchor and degrades to a copy-paste note. Pins both halves of that seam: - anchorsAnImprovementFromALaterBatchToItsAbsoluteLine — three files, one per batch, where the only improvement comes out of the last batch and names a file whose hunk starts at line 120. The per-call budget is derived from the real rendered sections, so the split is deterministic and nothing is clipped. - neverCommitsASuggestionToAFileTheRepositoryAskedTheBotToIgnore — the regression above; it fails on the previous revision. Refs #316
The README still described /improve the way it worked before batching: a single pass whose partial-coverage note fired when the diff exceeded REVIEW_MAX_DIFF_LINES, with the command listed among the single-call line-capped renders and among the on-demand commands that do not batch. None of that is true now, and an operator reading it would tune the wrong knob for coverage. Say instead that the pass packs the changed files into batches that each fit REVIEW_MAX_INPUT_TOKENS, that REVIEW_MAX_AI_CALLS is the ceiling on what a run spends (all of it, since the command assembles its summary locally rather than asking the model for one), and that the disclosure names the files the budget could not cover. Refs #316
There was a problem hiding this comment.
ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:
- Check trivy is pending
- Check format is pending
- Check changes is pending
- Check frontend is pending
- Check actionlint is pending
- Check test is pending
- Check build is pending
- Check dependency-review is pending
Additionally, 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.
|
/improve (#452) and /generate-tests both register a new comment command, so the enum, the ordered pattern map, the command switch, the /help table, the config key, and every README/.env/docs listing collided additively. Both sides are kept throughout, with /generate-tests ordered after /improve everywhere. AbstractPrSuggestionGenerator now resolves the reviewable file list before rendering the diff, so UnitTestGeneratorTest stubs the two-argument ReviewDiffFormatter.buildDiffStringWithStats(files, reviewable) that loadInputs(...) calls. Refs #36
## What type of PR is this? - [ ] 🐛 Bug fix - [x] ✨ Feature - [x] 📝 Documentation - [ ] 🔧 Refactor - [ ] 🚀 Performance - [x] ✅ Test - [ ] 🔒 Security - [ ] 📦 Dependency update - [ ] 🏗️ CI/CD ## Description Adds `/generate-tests`, an on-request command that asks the model to propose unit tests for the code the PR changed — a way to close coverage gaps surfaced during review without leaving the PR. **How a proposal is presented.** A generated test is normally a whole new file. GitHub's committable `suggestion` block replaces an anchored line range on an inline review comment, so a new file has nothing to anchor to; forcing one in would produce a broken commit when applied. Each proposed file is therefore rendered through `SuggestionFormatter` as a copy-paste block headed by the exact repository path it belongs at — the same "show the draft, don't commit it" shape `/add-docs` already falls back to when a declaration can't be pinned to a hunk. Nothing is committed and no file is edited. Everything in that comment is model output, so the rendering is hardened against a prompt-injected diff: the fence is widened past the longest backtick run in the test source, the language tag is dropped unless it looks like a language tag, and every model-supplied prose field — the path, the "covers" note, and the trailing "not covered" notes — is flattened to a single line through one shared rule (with the path's backticks removed), so none of them can break out of the structure around it. **Gating and failure behaviour.** The handler runs `ManualReviewAuthorizer` then `PrPauseService`, in the same order as the other on-request commands, behind the `REVIEW_GENERATE_TESTS_ENABLED` flag. Every load fails soft: no diff, an assistant error, or an unparseable reply all degrade to posting nothing rather than a noisy error on the PR. When the model judges nothing testable, the bot says so instead of staying silent — the maintainer asked explicitly. At most 5 files are rendered per comment, with a line naming how many were held back. When the diff was over budget the comment carries the shared partial-coverage disclosure, on the "nothing to test" outcome too, so that verdict can never read as a verdict on the whole PR. Files: - `review/UnitTestGenerator.java` — loads the diff/PR context/instructions/project stack, calls the assistant, renders the comment. Extends `AbstractPrSuggestionGenerator` and does not touch its diff loading, so it inherits token-budgeted batching when that lands. - `review/ai/UnitTestAssistant.java`, `UnitTestAssistantPrompts.java` — the LangChain4j service and its prompts; the diff, PR body, stack and repo instructions are escaped and framed as untrusted data. - `review/ai/UnitTestGenerationParser.java`, `UnitTestGenerationResponse.java` — JSON parsing, null-entry tolerance, and the postable-proposal filter. - `review/SuggestionFormatter.java` — `formatGeneratedTestFile(...)` plus the fence, language-tag and single-line hardening. - `webhook/CommentCommand.java`, `TriggerDetector.java`, `CommentCommandService.java` — the new command, its slash and mention patterns, the handler and the `/help` row. - `config/ThrillhouseConfig.java`, `application.properties`, `.env.example`, `README.md`, `docs/ARCHITECTURE.md`, `website/src/content/docs/index.md` — the flag and its docs. The flag defaults to `true`, matching `REVIEW_ADD_DOCS_ENABLED`: the command never runs automatically, only when a write-access holder asks for it, so the flag is the operator's kill switch for the AI budget rather than an opt-in. ## Related Issues Fixes #36 ## How Has This Been Tested? - [x] Unit tests - [ ] Integration tests - [ ] Manual testing `./mvnw -B clean test spotless:check spotbugs:check` — 2196 tests green, `BugInstance size is 0`, spotless clean. JaCoCo reports 100% line **and** branch coverage on all four new classes and on `SuggestionFormatter`, `TriggerDetector` and `CommentCommandService`. `cd website && npm ci && npm run build` (the `docs.yml` build job) — 66 pages, "All internal links are valid". The rendered `/commands/`, `/configuration/` and index pages all carry the new command and key. Every new test was mutation-proven: the production code was neutralized one behaviour at a time and the test had to fail. Verbatim failures below. ### Command routing and quoted-input safety | Mutation (production code) | Verbatim failure | |---|---| | `patterns.put(CommentCommand.GENERATE_TESTS, ...)` removed | `TriggerDetectorTest.shouldDetectEachSlashCommand:62 expected: <GENERATE_TESTS> but was: <NONE>` and `shouldDetectEachMentionCommand:76 expected: <GENERATE_TESTS> but was: <NONE>` | | `FENCED_CODE` stripping disabled | `shouldNotDetectGenerateTestsInsideQuotedContext:161 expected: <NONE> but was: <GENERATE_TESTS>` | | `~~~` dropped from `FENCED_CODE` | `shouldNotDetectGenerateTestsInsideQuotedContext:172 expected: <NONE> but was: <GENERATE_TESTS>` | | `BLOCKQUOTE_LINE` stripping disabled | `shouldNotDetectGenerateTestsInsideQuotedContext:170 expected: <NONE> but was: <GENERATE_TESTS>` | | `INLINE_CODE` stripping disabled | `shouldNotDetectGenerateTestsInsideQuotedContext:166 expected: <NONE> but was: <GENERATE_TESTS>` | | whole comment discarded whenever it contains any quoted context | `shouldStillDetectGenerateTestsAlongsideAQuotedOne:184 expected: <GENERATE_TESTS> but was: <NONE>` | | `GENERATE_TESTS` excluded from the webhook's command routing | `WebhookControllerTest.shouldRouteGenerateTestsCommandToCommandService:880 Wanted but not invoked: commentCommandService.handle(...) Actually, there were zero interactions with this mock.` | The inline-code assertion originally in this PR (`` run `/generate-tests` to propose ``) **survived** the inline-code mutation: an unpadded span already fails the slash pattern's whitespace boundary, so it never exercised the stripping. It was replaced with a padded span and the mention form, both of which do depend on it — the row above is the failure from the hardened version. ### Gating | Mutation | Verbatim failure | |---|---| | `generateTestsEnabled()` gate removed | `CommentCommandServiceTest.generateTestsIgnoredWhenDisabled:376 No interactions wanted here ... But found these interactions on mock 'authorizer'` | | `authorized(ctx)` gate removed | `generateTestsIgnoredWhenUnauthorized:364 No interactions wanted here ... But found these interactions on mock 'testGenerator'` | | `prPauseService.isPaused(...)` gate removed | `generateTestsPostsPausedNoticeWhenPaused:354 Wanted but not invoked: commentClient.createComment(...) Actually, there were zero interactions with this mock.` | | `case GENERATE_TESTS ->` removed from the switch | `generateTestsPostsTheGeneratedSuggestion:333 Wanted but not invoked: commentClient.createComment(...)` | | `suggestion == null` guard removed | `generateTestsPostsNothingWhenGeneratorReturnsNull:344 ... But invoked here: ... CreateCommentRequest[body=null]` | | `/generate-tests` row removed from `HELP_TEXT` | `helpListsTheGenerateTestsCommand:384 expected: <true> but was: <false>` | ### Generation flow | Mutation | Verbatim failure | |---|---| | header dropped from the rendered comment | `UnitTestGeneratorTest.rendersEachProposedTestFileAsACopyPasteBlock:113 ... ==> expected: <true> but was: <false>` | | `MAX_TEST_FILES` cap removed | `capsTheNumberOfRenderedTestFiles:180 ... expected: <true> but was: <false>` (Foo5/Foo6 rendered) | | "nothing warrants a test" message suppressed | `reportsThatNothingWarrantsATestInsteadOfStayingSilent:194 expected: <true> but was: <false>` | | model's coverage notes dropped | `reportsThatNothingWarrantsATestInsteadOfStayingSilent:195 ... expected: <true> but was: <false>` | | partial-coverage disclosure not appended | `appendsPartialCoverageDisclosureWhenTheDiffWasTruncated:225 expected: <\n\n>⚠️ **Large PR — partial coverage.** 48 file(s) were omitted ...> but was: <>` and `disclosesPartialCoverageEvenWhenNoTestsWereProposed:243 expected: <true> but was: <false>` | | disclosure appended unconditionally | `appendsNoDisclosureWhenNothingWasOmitted:255 ... expected: <true> but was: <false>` | | no-diff path returns `""` instead of `null` | `returnsNullWhenThereIsNoDiff:263 expected: <null> but was: <>` | | unparseable-reply path returns `""` instead of `null` | `returnsNullWhenTheResponseIsNotUsableJson:284 expected: <null> but was: <>` | | assistant failure rethrown instead of degrading | `returnsNullWhenTheAssistantThrows:274->generate:101 » Runtime model down` | | PR-details load no longer fails soft | `stillGeneratesWhenPrDetailsFetchFails:329->generate:101 » Runtime 404` | | project-stack load no longer fails soft | `stillGeneratesWhenTheProjectStackCannotBeResolved:316->generate:101 » Runtime github down` | | diff escaped instead of fenced | `fencesTheDiffAndPassesTheProjectStackToTheAssistant:302 expected: <true> but was: <false>` | | project stack not passed to the assistant | `fencesTheDiffAndPassesTheProjectStackToTheAssistant:305 expected: <pom.xml: junit> but was: <>` | | `{{projectStack}}` removed from the user prompt | `AiServicePromptRenderingTest.unitTestPromptIncludesEveryContextVariable:133 projectStack missing ==> expected: <true> but was: <false>` | | `@UserMessage` moved from the method to a parameter | `AiServiceUserMessagePlacementTest.unitTestAssistantPutsUserMessageOnTheMethod:51 UnitTestAssistant.generate must declare @Usermessage on the method so the template is rendered ==> expected: <true> but was: <false>` | ### Rendering and parsing | Mutation | Verbatim failure | |---|---| | path heading dropped | `SuggestionFormatterTest.shouldFormatGeneratedTestFileAsAPathHeadedCodeBlock:187 ... expected: <true> but was: <false>` | | path not flattened / backticks kept | `shouldKeepAModelSuppliedPathInsideItsHeadingCodeSpan:225 ... expected: <true> but was: <false>` (the injected `## Injected` heading escaped the code span) | | `covers` note not flattened | `shouldFlattenAMultiLineCoversNote:234 ... expected: <true> but was: <false>` | | `covers` line dropped entirely | `shouldFormatGeneratedTestFileAsAPathHeadedCodeBlock:188 ... expected: <true> but was: <false>` | | null path rendered literally | `shouldTolerateAMissingPathCoversAndCode:214 ### \`null\` ... expected: <true> but was: <false>` | | null code rendered literally | `shouldTolerateAMissingPathCoversAndCode:215 ... expected: <false> but was: <true>` | | fence never widened past backtick runs | `shouldWidenTheFencePastBacktickRunsInTheTestSource:198 ... expected: <true> but was: <false>` and `UnitTestGeneratorTest.widensTheFenceWhenTheTestSourceContainsAFencedBlock:137 ... expected: <true> but was: <false>` | | language tag not validated | `shouldOmitAnUnusableLanguageTag:206 ... expected: <true> but was: <false>` and `UnitTestGeneratorTest.dropsAModelSuppliedLanguageThatIsNotALanguageTag:155 ... expected: <true> but was: <false>` (the injected heading landed on the fence line) | | fenced-JSON unwrapping removed | `UnitTestGenerationParserTest.unwrapsAFencedJsonReply:50 » IllegalArgument Model response is not valid generate-tests JSON` | | JSON fields mis-bound (path/code swapped, covers+language nulled) | `parsesTheProposedTestFiles:39 expected: <src/test/java/FooTest.java> but was: <class FooTest {}>` | | `notes` not normalized to `""` | `normalizesMissingTestsAndNotes:66 expected: <> but was: <null>` | | null array entries not dropped | `dropsNullEntriesAndKeepsOnlyPostableProposals:73 » IllegalArgument Model response is not valid generate-tests JSON` | | `isPostable()` always true | `dropsNullEntriesAndKeepsOnlyPostableProposals:82 expected: <1> but was: <3>` | | null path/code no longer rejected by `isPostable()` | `dropsNullEntriesAndKeepsOnlyPostableProposals:84 expected: <1> but was: <2>` | | empty/blank reply not rejected | `rejectsAnEmptyOrUnparseableReply:88 Unexpected exception type thrown, expected: <java.lang.IllegalArgumentException> but was: <java.lang.NullPointerException>` | | model's `notes` not flattened to one line | `UnitTestGeneratorTest.flattensTheModelSuppliedNotesLine:212` — see below | The last row closes a gap found in review: `path` and the `covers` note were flattened through `SuggestionFormatter.oneLine(...)`, but `notes` was rendered with only `strip()`, so a reply whose notes carried a blank line and a fence broke out of the `**Not covered:**` line and rendered as live markdown. It now goes through the same `oneLine(...)` rule rather than restating the regex, so the three model-supplied prose fields cannot drift apart. With that flattening reverted, the test fails with the injected fence and heading rendering live: ```` org.opentest4j.AssertionFailedError: 🤖 ThrillhouseBot found nothing in this PR's changes that warrants a new unit test. **Not covered:** skipped IO ``` ## Injected run /pause ``` ==> expected: <true> but was: <false> at dev.thiagogonzaga.thrillhousebot.review.UnitTestGeneratorTest.flattensTheModelSuppliedNotesLine(UnitTestGeneratorTest.java:212) ```` Command precedence is also pinned now that `/improve` (#452) is an adjacent entry in `TriggerDetector`'s ordered pattern map. Quoted context is stripped from the whole body before any pattern runs, so a quoted `/improve` cannot divert a genuine `/generate-tests` (or the reverse) whatever the map order is; order only decides a genuine-vs-genuine contest, and that is pinned so a reorder cannot silently re-route an invocation to the other command's AI spend. | Mutation | Verbatim failure | |---|---| | fenced-code stripping disabled | `shouldNotLetAQuotedNeighborCommandStealARealOne:227 expected: <GENERATE_TESTS> but was: <IMPROVE>` | | inline-code stripping disabled | `shouldNotLetAQuotedNeighborCommandStealARealOne:232 expected: <GENERATE_TESTS> but was: <IMPROVE>` | | blockquote stripping disabled | `shouldNotLetAQuotedNeighborCommandStealARealOne:235 expected: <GENERATE_TESTS> but was: <IMPROVE>` | | `IMPROVE`/`GENERATE_TESTS` map order swapped | `shouldResolveACommentCarryingBothImproveAndGenerateTestsToTheFirstEntry:248 expected: <IMPROVE> but was: <GENERATE_TESTS>` | ## Checklist - [x] My code follows the project's coding standards - [x] I have performed a self-review of my own code - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings or errors ## Screenshots / Logs Shape of the posted comment (one section per proposed file): ````markdown ## 🤖 ThrillhouseBot — suggested unit tests ### `src/test/java/com/example/OrderServiceTest.java` OrderService.apply(Discount) rejects a negative percentage ```java package com.example; ... ``` --- *Suggestion only — nothing was committed. Create each file at the path shown ...* ```` ## Additional Notes - `AbstractPrSuggestionGenerator` is deliberately untouched — `git diff origin/release/v0.6.0...HEAD -- .../AbstractPrSuggestionGenerator.java` is empty. The command loads its diff through the shared `loadInputs(...)`/`Inputs` path as-is. - #452 (`/improve`) has since merged, and `release/v0.6.0` is merged into this branch in `ad07735`. Both commands register a new comment command, so the enum, the ordered pattern map, the command switch, the `/help` table, the config key and every README/`.env`/docs listing collided additively; both sides are kept, with `/generate-tests` ordered after `/improve` everywhere. No behaviour of this command changed in the merge; the only adjustment was to a test, because `loadInputs(...)` now resolves the reviewable file list before rendering the diff, so `UnitTestGeneratorTest` stubs the two-argument `ReviewDiffFormatter.buildDiffStringWithStats(files, reviewable)`. - **Pending #463.** This command still calls `inputs.omittedFiles()` at `UnitTestGenerator.java:133` for its partial-coverage disclosure. #463 lifts the batching seam into `AbstractPrSuggestionGenerator` and removes that field; once it merges this command adopts the seam — `disclosure(plan)`, `planBatches(...)` with this command's own prompts, per-batch text rather than the whole diff, and the per-repo-ignore-filtered file list as the authoritative one downstream. That call site is a known, tracked follow-up rather than an oversight; `PrDescriptionGenerator` and `ChangelogEntryGenerator` read the same field and migrate with it. - Known limitation, shared with the other on-request commands: the comment body is not length-capped against GitHub's 65,536-character limit. Five whole test files could in principle exceed it; the request then fails soft (logged, nothing posted). Worth a follow-up issue that caps all of the generated comments, rather than solving it for one command here.
…463) ## What type of PR is this? - [ ] 🐛 Bug fix - [x] ✨ Feature - [x] 📝 Documentation - [x] 🔧 Refactor - [ ] 🚀 Performance - [ ] ✅ Test - [ ] 🔒 Security - [ ] 📦 Dependency update - [ ] 🏗️ CI/CD ## Description `max-diff-lines` predates token budgeting and had become a second, cruder ceiling sitting in front of it. The review path stopped using it when map-reduce (#53) landed, and `/improve` was moved onto that in #316 — but `/describe` and `/changelog` still shrank a large PR to the first `max-diff-lines` of its rendered diff. A description was therefore written from a partial diff, and a CHANGELOG entry drafted from one, with whole files never reaching the model at all. **The seam.** The three methods `/improve` proved — batch planning, the shared prompt overhead, and the per-repo ignore re-filter — are lifted into `AbstractPrSuggestionGenerator`, parameterised by each command's own prompt constants. `/improve` is refactored onto the lifted versions rather than keeping a private copy. Every on-request suggestion command now plans batches over the reviewable **file list** under the per-call token budget and makes one call per batch. One call: ```java var plan = planBatches(reviewable, inputs, ownSystemPrompt, ownUserPrompt, reservedCalls); ``` **The reduce step is per-command, because the reductions genuinely differ.** Batching is only the map step: | Command | Reduce | Extra AI call? | |---|---|---| | `/describe` | Per-batch partial descriptions **synthesized** into one coherent title + description | Yes — reserved, spent only when >1 batch | | `/changelog` | Per-batch candidate entries **merged** into one entry | Yes — reserved, spent only when >1 candidate | | `/improve` | Local union of per-batch suggestions, deduped by `file:line` | No | | `/generate-tests` | Local union of per-batch test files, deduped by path | No | Nothing is concatenated. Stapling `/describe`'s partials together repeats the overview once per part and reads as several pull requests; for `/changelog`, a deterministic merge could unify headings and drop identical bullets, but the duplicates that actually arise are *not* identical — two batches that saw different files of one feature describe that change in two different sentences, which only a reader that understands them can collapse. Both reduce calls are reserved out of `max-ai-calls` up front, the same way the review path reserves one for its summary, so a run never exceeds the ceiling of one review; a single-batch PR still costs exactly one call. **Also in this change** - The shared overhead is assembled from each command's **own** prompts. Sizing a batch against another command's prompts would let an "in-budget" batch overshoot the real input limit. - Coverage disclosure now comes from `BudgetPlan.omittedFiles()` / `clippedFiles()` — files **named**, not counted — keeping #296's wording. `Inputs.omittedFiles` (the line-cap count) is gone, so nothing can reach for the wrong number. - Coverage on a huge PR is bounded by `max-ai-calls`, not the file list. Files that never got a batch are named. When *no* file fit any batch at all, every command says so and names the files rather than going silent — a misconfigured budget must not look like a bot that ignored the command. An empty plan that omitted *nothing*, because the repository ignores every changed file, is the opposite case and posts nothing. - Per-repo ignore patterns (#449) are applied on top of the global set for every command, and the filtered list stays authoritative for everything downstream — batches *and* the line map alike. That is the bug #452's audit found; the lifted method carries the invariant in its javadoc. - `max-input-tokens <= 0` keeps budgeting off as a single uncapped batch rather than regressing to the line-capped string. - A batch whose model call fails is skipped rather than failing the run, and the shortfall is disclosed. - Sizing callers reach the prompts through `systemPrompt()` / `userPrompt()` accessors: a reference to a `static final String` is inlined into the caller's class file at compile time, and a third copy of a multi-kilobyte prompt trips SpotBugs' `HSC_HUGE_SHARED_STRING_CONSTANT`. The accessors are deliberately *not* named `system()` / `user()` — differing from the constant only by capitalization reads as a typo at the call site. **`/add-docs` is deliberately out of scope.** It does not extend `AbstractPrSuggestionGenerator`; it orders its loading around a hard head-SHA precondition (every output is an inline suggestion, so no head SHA means nothing postable and a distinct user-facing message), and it feeds its assistant a different input set — project stack, a combined `PromptSections.prContext(...)` block, and a pre-rendered instructions section built from `ResolvedInstructions` rather than the content string the shared `Inputs` carries. Folding it in therefore means changing the shared `Inputs` contract at the same time as first lifting the seam, on the command that posts committable edits. It is worth doing and should be tracked separately; `/add-docs` remains line-capped and the README now says so precisely. Note it also still has **no per-repo ignore filter at all**, which is worth carrying into that follow-up. **Path-scoped instructions (#460) are not part of what a command batch carries.** `PathScopedInstructions` is resolved only by `ReviewContextLoader` and rendered only into `ReviewPromptAssembler`'s trailing-guidance slot, so it reaches the review prompt and nothing else. The `repoInstructions` slot of the batched commands is fed solely by `InstructionsResolver.resolve(...).content()` — the global instructions file — which `sharedPromptOverhead(...)` already counts in full. No batch is mis-sized by the scoped rules. ## `/generate-tests` is migrated onto the seam in this PR #461 merged before this one, so `/generate-tests` landed on `release/v0.6.0` still line-capped and still reading `Inputs.omittedFiles`, which this PR removes. The migration #461's agent was going to perform *after* this merged is therefore done **here** — there is no "later", because without it base does not compile. `UnitTestGenerator` now: 1. resolves its effective file list once via `respectPerRepoIgnores(...)` and plans from it, 2. plans token-budgeted batches and sends `batch.text()` per batch, 3. discloses coverage from `disclosure(plan)`, 4. reserves **0** calls — its reduce is a local union, so the whole `max-ai-calls` allowance buys batches. **Two things this surfaced that are worth reading closely.** **The shared overhead was not sufficient for this command, and using it unchanged would have been a real bug.** `sharedPromptOverhead(...)` counts system + user + fence + title + body + instructions. `/generate-tests` also sends the resolved **project stack** on every call — dependency manifests, kilobytes, not a rounding error — so the estimate would have undercounted every batch by the size of the stack and let "in-budget" batches overshoot the model's real input limit. That is precisely the failure the overhead exists to prevent. `planBatches(...)` therefore gains a six-argument form taking the command's own extra per-call sections, and `/generate-tests` declares the stack there. A future adopter with its own extra section must do the same rather than reach for the five-argument form. **It uses its own prompt templates, not the shared ones.** `UnitTestAssistant` is annotated with `UnitTestAssistantPrompts.SYSTEM` / `UnitTestAssistantPrompts.USER` — it does **not** share `PrSuggestionPrompts.USER`, because its user template carries the project-stack section. Sizing its batches against the shared user template would measure the wrong prompt. `UnitTestAssistantPrompts` had no accessors (it is new from #461), so `systemPrompt()` / `userPrompt()` are added to it — SpotBugs failed the build without them, exactly as the accessor javadoc predicts. **Why dedupe by path rather than merge.** Batches partition the file list, so two batches usually propose disjoint test paths. When they do collide, each proposal's `code` is a *complete* compilable file — package, imports and fixtures included, posted verbatim to paste at that path — so two of them at one path are alternatives, not additions. Rendering both would invite pasting the second over the first and silently losing the first's cases, and merging them properly would need a model call for a rare collision. The first wins and the rest are counted in a disclosure line, so the maintainer can re-run for the others. ## Related Issues Fixes #457 ## How Has This Been Tested? - [x] Unit tests - [ ] Integration tests - [ ] Manual testing Every new behavior was validated red/green: the test was written, the production change was mutated to neutralize exactly that behavior, the test was confirmed to **fail**, and the mutation was reverted to confirm it passes. **One mutation initially stayed green and the test was rewritten.** `proposesTestsForFilesThatTheLineCapWouldHaveDroppedEntirely` first asserted only that each batch *contained* its file — which is also true when every call is handed the whole-PR diff, the very behavior being replaced. Strengthened to assert the partition (batch 1 contains `Foo` and **not** `Other`, batch 2 the reverse), it goes red properly: ``` [ERROR] UnitTestGeneratorTest.proposesTestsForFilesThatTheLineCapWouldHaveDroppedEntirely:439 [[THRILLHOUSEBOT-UNTRUSTED-DATA-3d24804df8d0a8a72a18bb0d9f6a121f]] ``` **The project stack is counted in the budget.** Mutation: use the five-argument `planBatches(...)`, leaving the stack out of the overhead. With a 20k-character stack no file can honestly fit, so the correct run makes no call at all; the mutant ships batches that overshoot: ``` [ERROR] UnitTestGeneratorTest.countsTheProjectStackInTheBudgetSoBatchesAreNotOversized:496 No interactions wanted here: ``` **Per-repo ignores stay authoritative.** Mutation: plan from `inputs.reviewableFiles()` instead of the filtered list. ``` [ERROR] UnitTestGeneratorTest.leavesFilesTheRepositoryAskedTheBotToIgnoreOutOfScope:520 [[THRILLHOUSEBOT-UNTRUSTED-DATA-5c207a4fc3e69a526faf47a8f7df5769]] [ERROR] UnitTestGeneratorTest.staysSilentWhenEveryChangedFileIsOutOfScope:574 ``` **Same-path proposals are deduped.** Mutation: drop the `seenPaths` guard. ``` [ERROR] UnitTestGeneratorTest.keepsOneProposalPerPathAndSaysHowManyWereLeftOut:479 ## 🤖 ThrillhouseBot — suggested unit tests ``` **Disclosure comes from the budget plan.** Mutation: `disclosure(plan)` returns `""` and the empty-plan branch returns `null`. ``` [ERROR] UnitTestGeneratorTest.disclosesPartialCoverageEvenWhenNoTestsWereProposed:321 🧪 ThrillhouseBot found nothing in this PR's changes that warrants a new unit test. ==> expected: <true> but was: <false> [ERROR] UnitTestGeneratorTest.namesTheFilesLeftUncoveredWhenTheBatchBudgetRunsOut:303 ## 🤖 ThrillhouseBot — suggested unit tests [ERROR] UnitTestGeneratorTest.namesTheFilesWhenTheBudgetCouldNotCoverASingleOne:555 expected: not <null> ``` Earlier rounds for `/describe`, `/changelog` and `/improve` (batch text vs. line-capped render, synthesis vs. concatenation, reserved reduce call, per-repo ignores, disclosure, budgeting-disabled, nothing-covered, merge declines) all went red as recorded before; the `<= 0` guard mutation remains the one that does not, because `max-input-tokens=0` reaches `Integer.MAX_VALUE` down the fall-through path and yields the same single batch. Build results, on the merge of `release/v0.6.0` at `ad36d22` (#458, #460, #464, #459, #461): ``` ./mvnw -B spotless:apply # clean ./mvnw -B clean compile spotbugs:check spotless:check # BUILD SUCCESS ./mvnw -B clean test # Tests run: 2226, Failures: 0, Errors: 0, Skipped: 0 cd website && npm run build # "All internal links are valid." ``` ## Checklist - [x] My code follows the project's coding standards - [x] I have performed a self-review of my own code - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings or errors ## Notes for anything still in flight `Inputs.omittedFiles` is gone. It carried how many files the `max-diff-lines` render dropped; once a command plans its own token-budgeted batches that number describes a render nothing sends to a model, so it is not merely redundant but wrong. Nothing on `release/v0.6.0` or in this tree still reads it. Any command extending `AbstractPrSuggestionGenerator` that is still in flight needs the same four steps `/generate-tests` just took: 1. `disclosure(plan)` rather than a line-cap count. 2. `planBatches(reviewable, inputs, <its own system prompt>, <its own user prompt>, reservedCalls)` — its **own** prompt constants, and the six-argument form if it repeats a section the shared overhead does not know about. Check the merged tree for whether the class exposes `systemPrompt()` / `userPrompt()` accessors or only constants; adding them is required if a sizing reference would inline a third copy. 3. `batch.text()` per batch, never `inputs.diff()`. 4. `respectPerRepoIgnores(target, COMMAND, inputs.reviewableFiles())`, with that same list used for anything that anchors onto the diff. ## Additional Notes **Operator-visible cost change.** `/describe` and `/changelog` on a PR that needs more than one batch now cost one more model call than the batches alone, reserved out of `REVIEW_MAX_AI_CALLS`, so the ceiling per run is unchanged. `/improve` and `/generate-tests` reserve nothing. Documented in the config table, the "AI call budget" section, the command prose, and the Known limitations bullet. **The ignore filter is authoritative for the line resolver, not just the planner.** `/improve` threads the resolved list into both `planBatches(...)` and `post(...)`, where the resolver is built as `new DiffLineResolver(diffFormatter().patchesByReviewableFiles(reviewable))` — never from `inputs.reviewableFiles()`. `PrImprovementServiceTest.neverCommitsASuggestionToAFileTheRepositoryAskedTheBotToIgnore` pins it. `/describe`, `/changelog` and `/generate-tests` build no line map (a proposed test file is a new file with no diff line to anchor to), so they cannot exercise it, but the rule is stated in `respectPerRepoIgnores(...)`'s javadoc for future adopters. **New prompts.** `PrDescribeAssistantPrompts.SYNTHESIS_SYSTEM`/`SYNTHESIS_USER` and `ChangelogAssistantPrompts.MERGE_SYSTEM`/`MERGE_USER`, with matching `synthesize(...)` / `merge(...)` methods on the assistants. Both user templates mirror `PrSuggestionPrompts.USER` — same context sections, same random-fence untrusted-data block — with the partials/candidates in place of the diff. `AiServicePromptRenderingTest` drives both through the real rendering pipeline and asserts every `@V` reaches the message. **No new config keys.**



What type of PR is this?
Description
PR-Agent exposes
/improve, a dedicated pass that proposes broad, committable improvements across an entire PR. ThrillhouseBot only attached inlinesuggestionblocks to findings produced by the review pipeline, so there was no way to ask for an "improve this change set" pass.This adds
/improve(and the@thrillhousebot improvemention form) as an on-demand, write-gated command:PrImprovementServiceplans batches over the reviewable file list withDiffBudgetPlanner, the way the review path has worked since spike(review): define a better large-diff handling strategy #53, and makes one model call per batch. It extendsAbstractPrSuggestionGeneratorfor the PR title/body and the resolved repository instructions (.github/thrillhousebot.mdand the fallback chain), with the same fail-soft degradation the other on-request commands use.suggestion_oldanchors cleanly onto the diff is posted as an inline```suggestionblock on the lines it replaces. A single-line replacement must land on the exact reported line and reproduce that line's text including its leading indentation; a multi-line replacement is anchored by its verbatim range so it overwrites the whole span rather than only its first line.ReviewResult.truncationDisclosure(int, TruncationDetail)overload mirroring the existingtruncationNotice(int, TruncationDetail). The on-demand surface therefore upholds the same "reported by name, never silently dropped" contract as the review banner (same standard as fix(review): /describe, /changelog, /add-docs run on a truncated diff with no partial-coverage disclosure #296).ManualReviewAuthorizer, the command is refused with the paused notice while a PR is paused, both suggestion kinds count againstmax-review-comments, and the whole command sits behind a new kill switch.AbstractPrSuggestionGenerator.Inputsnow also carries the head SHA and the ignore-filtered file list — both already fetched byloadInputsand simply discarded before — so a command can anchor its output back onto the diff without a second round of API calls.How a run is planned
DiffBudgetPlannerorders files highest-impact-first and packs them First-Fit-Decreasing into bins that each fit the per-call input budget. A file too large for one bin is hunk-clipped; one that still does not fit is reported by name.max-ai-calls, notmax-diff-lines. The line cap no longer gates what the model sees. Unlike a review,/improvemakes no final summary call — its summary comment is assembled locally — so the wholemax-ai-callsallowance goes to batches (a review reserves one). Files that never get a batch are named in the summary.sharedPromptOverhead(...)is built fromPrImproveAssistantPrompts.SYSTEM+PrSuggestionPrompts.USER(the actual@UserMessageonPrImproveAssistant.improve) + the fence scaffolding + the escaped title/body/instructions — exactly the non-diff textgenerateOne(...)sends, and nothing fromPrReviewPrompts. Sizing batches against the review path's prompts would let every "in-budget" batch overshoot the real input limit.DiffLineResolveris built once from the effective reviewable file list — never from a batch — so an improvement produced by batch 3 still anchors to its correct absolute line.Why batching, not
max-diff-linesThe first version of this command took the pre-#53 route: a single call over the diff string that
ReviewDiffFormattercaps atmax-diff-lines. For a command whose entire value proposition is covering the whole change set, silently shrinking to the first N lines is the wrong failure mode — and it is not hypothetical. On a change set with the cap set low, the model received literally this and nothing else:Batching sizes by tokens over the file list, so a long diff only loses coverage once it exceeds the whole budget. Truncation becomes the rare genuinely-over-budget fallback rather than the design.
Cost. One model call per batch, capped at
max-ai-calls(default 6) — so an/improvecosts at most what one review costs, and typically far less, since most PRs fit in a single batch. It is not free, though: a large PR that used to cost one call can now cost up to six. Loweringmax-ai-callslowers the ceiling for both commands.The seam #457 builds on
#457 moves
/describe,/changelogand/add-docsonto the same batching. The seam is the pair of methods inPrImprovementService:Everything else those commands need is already shared:
AbstractPrSuggestionGenerator.Inputscarries the ignore-filteredreviewableFiles,DiffBudgetPlanner.plan(reviewable, overhead, perCallInputBudget(), maxBatches)is package-visible, andReviewResult.truncationDisclosure(int, TruncationDetail)renders the disclosure. So each command needs only its own overhead string. The natural refactor is to liftplanBatchesandsharedPromptOverheadintoAbstractPrSuggestionGenerator, parameterised by the caller's prompt constants;respectPerRepoIgnores(...)should move up with them (it is wired only into/improvehere to avoid changing the other three commands' behaviour in this PR).Respecting per-repo ignore patterns
Now that every file is in scope, the per-repo ignore patterns from #449 are applied on top of the deployment-wide set. While the pass stopped at
max-diff-lines, a repo-ignored file beyond the cap was excluded by accident; without this it would newly receive committable suggestions against generated or vendored code a repository explicitly asked the bot to leave alone. Per-repo patterns are strictly additive, so the already-filtered list is narrowed again — no extra diff fetch — and it fails soft to the global set exactly like the review path.The same effective list also backs the line map, so an ignored file cannot be reached by a hallucinated path either: a suggestion naming one no longer resolves, and degrades to a copy-paste note instead of a one-click commit.
Why the anchoring is stricter than
/add-docs/add-docsonly ever inserts a doc comment above a declaration, so a quote that matches loosely is harmless./improverewrites the line, and committing a suggestion replaces the anchored range withsuggestion_newverbatim. A model that re-indented the code it quoted has almost certainly re-indented its replacement too, so a loose match would silently reflow the line — and in an indentation-sensitive language, change what the code means./improvetherefore requires an exact match (trailing whitespace excepted) and fails closed when the line text cannot be read: an unverifiable line must not be rewritten on the author's behalf. Anything that does not anchor becomes a copy-paste block a human applies deliberately. The prompt asks for character-exact indentation insuggestion_oldso this path stays the common one rather than the fallback.Configuration
New key
thrillhousebot.review.improve-enabled/REVIEW_IMPROVE_ENABLED, documented inREADME.mdand.env.example.It defaults to
true, matching theadd-docs-enabledprecedent rather than the usual "new flag defaults to current behaviour" rule: the command never runs automatically, requires write access, and only spends AI budget when a maintainer explicitly asks for it. Flagging that here explicitly in case you would rather ship it opt-in — it is a one-line change to@WithDefault.No new key was needed for batching: it reuses
REVIEW_MAX_INPUT_TOKENS,REVIEW_TOKEN_SAFETY_MARGIN,REVIEW_OUTPUT_BUFFER_TOKENSandREVIEW_MAX_AI_CALLS. Their README rows now say so, andREVIEW_MAX_DIFF_LINESno longer lists/improveamong the single-call line-capped renders.Documentation
The docs site's current-version pages
include:the README sections, so the README edits flow into them automatically; the0.1.0–0.4.0trees are frozen archives and are untouched.website/src/content/docs/index.mdkeeps its own hand-maintained command teaser, which listed every on-demand generation command except the new one, so it is updated too.npm ci && npm run buildinwebsite/passes with "All internal links are valid."docs/COMPARISON.mdwas left untouched — it compares licensing, hosting, model and cost dimensions and carries no command matrix, so there is nothing there to update.Related Issues
Fixes #316
How Has This Been Tested?
PrImprovementServiceTest(35 tests),ImprovementParserTest(6),ImprovementResponseTest(12), plus additions toSuggestionFormatterTest,TriggerDetectorTest,CommentCommandServiceTestandWebhookControllerTest. Full suite: 2112 tests, 0 failures, 0 errors.Every behaviour was validated by neutralizing only the production change — never the test — and confirming a real assertion failure.
Round 1 — the command itself
1. Command detection — removed the
IMPROVEentry fromTriggerDetector.buildPatterns():2. Command routing — removed the
IMPROVEswitch arm and the help-table row fromCommentCommandService:3. Generation / committable suggestions — made
PrImprovementService.postInlinereturnfalseunconditionally:(also red:
anchorsAMultiLineImprovementAcrossItsWholeRange,capsThePerRunCommentCount)Round 2 — the indentation fix
Reverting the comparison to the original
.strip()form, keeping the test:That is the defect itself: with
.strip()the mis-indented rewrite was posted as a one-click commit. Green once the fix is restored.anchorsAQuoteThatReproducesTheLeadingIndentationandanchorsDespiteInsignificantTrailingWhitespaceInTheDiffcover the other side, so the stricter rule cannot silently swallow correct suggestions.Round 3 — coverage
codecov/patchwas red at 85.33% with 33 uncovered lines, all of them fail-soft and degradation paths — the ones that matter most for work running off the webhook ACK thread. Now covered: malformed and empty model responses,isPostable()for every missing field, a PR with a blank or absent head ref, a formatter yielding no reviewable file list, a blank or absent rendered diff, a multi-line replacement whose range cannot be resolved, a reported line that only snaps to a neighbour, the per-run cap swallowing every improvement, and a summary post that throws. Each new test was proven red first, e.g. withisPostable()stubbed toreturn true:Round 4 — token-budgeted batching, mutation-tested per test
Each batching test was mutation-tested individually: one minimal change to production code,
./mvnw -B test -Dtest=PrImprovementServiceTest, then restore. Verbatim failures:coversFilesThatTheLineCapWouldHaveDroppedEntirely— sentinputs.diff()(the line-capped string) instead of the batch text:That is the whole case for this change: under the old design the model was handed a truncation notice and zero file content. The same mutation also reddened
sendsOneUncappedBatchWhenTokenBudgetingIsDisabledandleavesFilesTheRepositoryAskedTheBotToIgnoreOutOfScope.splitsAnOversizedChangeSetAcrossBatchesAndDedupesTheResults— dropped theseen.add(dedupeKey(improvement))guard:neverSpendsMoreModelCallsThanMaxAiCalls—maxBatches()returningInteger.MAX_VALUE:The same mutation also reddened
namesTheFilesLeftUncoveredWhenTheBatchBudgetRunsOut— with unbounded batches the summary says "partially analyzed" instead of naming the files no batch ever reached.disclosesFilesTheTokenBudgetCouldNotCoverByName— disclosure passed only a count, dropping theTruncationDetail:sendsOneUncappedBatchWhenTokenBudgetingIsDisabled— the budgeting-off path planning with a 1-token diff budget instead of an uncapped one:keepsImprovementsFromTheBatchesThatSucceededWhenOneBatchFails—if (failed == plan.batches().size())weakened toif (failed > 0):leavesFilesTheRepositoryAskedTheBotToIgnoreOutOfScope—respectPerRepoIgnoresshort-circuited to the global list:doesNotDiscloseTruncationForTheLineCapWhenTheBudgetCoveredEverything— re-appended the oldReviewResult.truncationDisclosure(inputs.omittedFiles()):That is the false partial-coverage claim the test exists to forbid: 48 files the line cap dropped, against a plan that batched every file within budget.
toleratesAFormatterThatYieldsNoReviewableFileList— removed the empty-plan early return:One mutation survived, and it should have. Narrowing the
activeModel.maxInputTokens() <= 0guard inplanBatchesto< 0leaves the whole class green. That is correct rather than a hole: with budgeting off,perCallInputBudget()already returnsInteger.MAX_VALUE, so the general path also produces one uncapped batch. The branch exists to skip the BPE pass entirely and to mark the planbudgeted=false, mirroring the identical guard inDiffBudgetPlanner.plan(reviewable, PromptInputs); it is kept for parity with the review path, not as behaviour the tests need to pin. The behaviour it protects is pinned — the same test dies under both theplan(reviewable, 1, 1)mutation above and the line-capped-string mutation.Batch budgets in these tests are derived from the real prompt overhead via
TokenCounterrather than hardcoded, so editing a prompt cannot silently turn them into no-ops by making every file overflow.Two earlier tests changed meaning rather than being deleted, since the design change invalidated their premise: the old "disclosure comes from the line cap" test is now
doesNotDiscloseTruncationForTheLineCapWhenTheBudgetCoveredEverything— a formatter reporting 48 line-omitted files against a plan that covered every file must produce no warning, since a false partial-coverage claim is its own defect. And a formatter yielding no reviewable files now reports "no reviewable changes" instead of falling through to the model, because there is nothing to plan batches over.Round 5 — cross-batch anchoring and ignore scope
Two gaps the batching commit left untested, closed here.
anchorsAnImprovementFromALaterBatchToItsAbsoluteLine— a three-file PR, one file per batch, where the only improvement comes out of the last batch and names a file whose hunk starts at line 120. Committing a rewrite to the wrong line is the exact failure the stricter anchoring exists to prevent, so it needs a multi-batch fixture rather than an argument. The per-call budget is computed from the real rendered sections (oneFilePerBatchBudget), so the split is deterministic and nothing is clipped. Building the line map from the first batch instead of the whole PR:(The same mutation also reddens
coversFilesThatTheLineCapWouldHaveDroppedEntirely, which anchors onto the second file of a single batch.)neverCommitsASuggestionToAFileTheRepositoryAskedTheBotToIgnore— this one found a real, if narrow, hole. The batches were planned over the ignore-filtered list, butDiffLineResolverwas still built frominputs.reviewableFiles(), the pre-per-repo-ignore list. A model naming an ignored file therefore still resolved against it and got a committable suggestion posted onto code the repository asked the bot to leave alone. Fixed by computing the effective list once inhandle(...)and threading it into both the planner and the resolver — the compute-once disciplineReviewContextLoaderalready uses. With the resolver reverted toinputs.reviewableFiles():src/Other.javais the file the repository asked the bot to ignore, and that is a one-click commit against it.Coverage
Local JaCoCo on
PrImprovementService: every line covered, and exactly one partial branch out of 78 — thecurrent != nullarm ofquotesCurrentLine. It is the same guard Codecov has been flagging since Round 3; it only moved down the file as batching was added (line 309 → 445), so it is not a new gap. Nothing in the batching or anchoring code is uncovered. It is unreachable by construction:postInlineonly calls it afterresolveRightSideLinereturned the exact line, andDiffLineResolver.appendRightSidedoeslineText.put(...)unconditionally for every line it adds tolines. It is kept as fail-closed defence against a future divergence rather than deleted to buy a percentage point.DiffBudgetPlanneris 100% line and 100% branch.ThrillhouseBot's own findings on this PR
Dogfooding, so all three are recorded with a verdict.
🟡 MEDIUM — "Lenient whitespace comparison may allow un-anchorable suggestions" (
quotesCurrentLine): conclusion right, reasoning wrong. Fixed, and hardened beyond the proposal.The stated mechanism is not correct: GitHub does not validate
suggestion_oldat all. Committing a suggestion replaces the anchored line range with the block body verbatim, so it cannot "fail to apply" —suggestion_oldis purely this codebase's own anchor.The conclusion is right for a different reason, and it is a real defect:
DiffLineResolverstores right-side text asrawLine.substring(1), so the diff marker is gone but the indentation is preserved. Comparing with.strip()therefore matched an un-indented quote, and the committable suggestion we posted carried the model's un-indented replacement — committing it reflowed the line.The suggested patch kept
current == null ||, i.e. fail open. Under a verbatim-commit contract "cannot verify this line" must mean "do not rewrite it", so the fix inverts that too:🔵 LOW — "Missing newline after opening code fence in formatImprovementBlock": wrong. Patch not applied; it would have introduced the defect it describes.
CODE_FENCE_CLOSEwas"\n```\n"— it already carries a newline on both sides, and is used as the opening delimiter as well as the closing one. Applying the suggested patch verbatim and running the byte-exact rendering test (newlines shown escaped, since the values contain fences themselves):— a blank line as the first and last line inside every code block, in
/add-docsnotes as well as/improveblocks.The finding reasoned from the constant's name rather than its value, and the name was genuinely misleading, so the underlying issue is fixed instead:
CODE_FENCE_CLOSEis renamed toCODE_FENCEwith the value and rendered output unchanged, and both renderings are now locked by byte-exact assertions.🔵 LOW — "Verify that file list retrieval paginates to avoid silent truncation" (
SoftLoaders.files): false positive — but a well-formed one.The concern was that
SoftLoaders.filesmight take GitHub's 30-per-page default and silently drop everything beyond the first page, which under batching would matter more than before. It does not:SoftLoaders.filescallsGitHubPullRequestClient.getPullRequestFiles, which walks pages ofFILES_PER_PAGE = 100up toMAX_FILE_PAGES = 30— 3000 files, which is GitHub's own cap on the files endpoint. No change made.Worth recording how this one was raised, because it is the shape a low-confidence finding should have: the code it flagged is outside this diff, so it hedged to LOW, said explicitly that the file was "not shown in this diff", asked for verification rather than asserting a bug, and was routed to "Things to double-check" in the summary instead of an inline thread. That is the correct handling for a hypothesis about unseen code — the check cost a minute and the alternative (staying silent about a plausible whole-PR-coverage bug) would have been worse.
Other
Quoted-input safety per
.github/thrillhousebot.md:/improveinside fenced code blocks, blockquotes and inline code is asserted not to trigger the command, while a genuine/improvealongside a quoted one still fires.All four SonarCloud issues raised against this PR are resolved (S5976 — three structurally identical tests merged into one parameterized test; two S6126 — text blocks, each verified byte-identical by comparing the resulting
Stringvalues, not by the tests still passing; S135 — the loop'scontinueremoved by inverting the guard).Local gates:
./mvnw -B spotless:applyclean,./mvnw -B clean compile spotbugs:check spotless:checkreportsBugInstance size is 0,./mvnw -B clean testreports Tests run: 2112, Failures: 0, Errors: 0, Skipped: 0.Checklist
Screenshots / Logs
The prompt the model actually received under the old single-call design, on a two-file change set with the line cap set to 4 — captured verbatim from the assertion failure when the batching change is reverted:
Additional Notes
PrDescriptionGeneratorTestandChangelogEntryGeneratorTesteach needed a two-line stub update:loadInputsnow calls the two-argumentbuildDiffStringWithStats(files, reviewableFiles)overload (the one-argument form computed the reviewable list internally and threw it away), so the mocked formatter stubs had to match the new arity. No assertions changed.Known follow-ups, deliberately out of scope:
DiffLineResolver.resolveSuggestionRange, which is shared with the review/finding path. Applying the same indentation strictness there would change behaviour for other commands and belongs in its own change.