Skip to content

feat(review): /improve — whole-PR improvement pass (PR-Agent parity) - #452

Merged
devops-thiago merged 11 commits into
release/v0.6.0from
feat/316-improve-command
Aug 8, 2026
Merged

feat(review): /improve — whole-PR improvement pass (PR-Agent parity)#452
devops-thiago merged 11 commits into
release/v0.6.0from
feat/316-improve-command

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

PR-Agent exposes /improve, a dedicated pass that proposes broad, committable improvements across an entire PR. ThrillhouseBot only attached inline suggestion blocks 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 improve mention form) as an on-demand, write-gated command:

  • Whole-PR pass, token-budgeted. PrImprovementService plans batches over the reviewable file list with DiffBudgetPlanner, 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 extends AbstractPrSuggestionGenerator for the PR title/body and the resolved repository instructions (.github/thrillhousebot.md and the fallback chain), with the same fail-soft degradation the other on-request commands use.
  • Committable suggestions. Every improvement whose quoted suggestion_old anchors cleanly onto the diff is posted as an inline ```suggestion block 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.
  • Copy-paste fallback. Improvements that cannot be pinned to the diff (or that GitHub rejects) are surfaced as copy-paste blocks in the run's summary comment instead of being dropped.
  • Coverage disclosure. The summary discloses the plan's omitted and clipped files — genuinely over-budget ones — and names them, via a new ReviewResult.truncationDisclosure(int, TruncationDetail) overload mirroring the existing truncationNotice(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).
  • Gating. Write access is enforced by ManualReviewAuthorizer, the command is refused with the paused notice while a PR is paused, both suggestion kinds count against max-review-comments, and the whole command sits behind a new kill switch.

AbstractPrSuggestionGenerator.Inputs now also carries the head SHA and the ignore-filtered file list — both already fetched by loadInputs and 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

loadInputs(...)                       diff + title + body + instructions + reviewable files
  └─ respectPerRepoIgnores(...)       #449 globs applied on top of the global set  ──┐
       └─ planBatches(reviewable)                                                    │
            ├─ max-input-tokens <= 0  → one uncapped batch                           │
            └─ otherwise              → DiffBudgetPlanner.plan(                      │
                                            reviewable,                              │
                                            sharedPromptOverhead(inputs),            │
                                            perCallInputBudget(),                    │
                                            maxBatches())                            │
  └─ generate(...)  one assistant call per batch, merged and deduped by file:line    │
  └─ post(...)      DiffLineResolver over the SAME effective file list ──────────────┘
  • Batches come from the file list, not the diff string. DiffBudgetPlanner orders 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.
  • What bounds coverage now is max-ai-calls, not max-diff-lines. The line cap no longer gates what the model sees. Unlike a review, /improve makes no final summary call — its summary comment is assembled locally — so the whole max-ai-calls allowance goes to batches (a review reserves one). Files that never get a batch are named in the summary.
  • Shared prompt overhead is this command's own. sharedPromptOverhead(...) is built from PrImproveAssistantPrompts.SYSTEM + PrSuggestionPrompts.USER (the actual @UserMessage on PrImproveAssistant.improve) + the fence scaffolding + the escaped title/body/instructions — exactly the non-diff text generateOne(...) sends, and nothing from PrReviewPrompts. Sizing batches against the review path's prompts would let every "in-budget" batch overshoot the real input limit.
  • Anchoring stays whole-PR. The DiffLineResolver is 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.
  • Merging and dedupe. Results are merged across batches and deduped by file and line, so two batches can never propose the same line twice.
  • Partial failure is survivable. A batch whose call or parse fails is skipped and disclosed rather than failing the run; only an all-batches failure posts the failure notice.

Why batching, not max-diff-lines

The first version of this command took the pre-#53 route: a single call over the diff string that ReviewDiffFormatter caps at max-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:

## Overview: 2 files (+7 -0)

(diff truncated at 4 lines — 2 files omitted)

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 /improve costs 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. Lowering max-ai-calls lowers the ceiling for both commands.

The seam #457 builds on

#457 moves /describe, /changelog and /add-docs onto the same batching. The seam is the pair of methods in PrImprovementService:

// budget → batches, over an already-ignore-filtered file list
private DiffBudgetPlanner.BudgetPlan planBatches(List<FileDiff> reviewable, Inputs inputs)
// the non-diff text this command's own calls repeat, so the planner can subtract it
private String sharedPromptOverhead(Inputs inputs)

Everything else those commands need is already shared: AbstractPrSuggestionGenerator.Inputs carries the ignore-filtered reviewableFiles, DiffBudgetPlanner.plan(reviewable, overhead, perCallInputBudget(), maxBatches) is package-visible, and ReviewResult.truncationDisclosure(int, TruncationDetail) renders the disclosure. So each command needs only its own overhead string. The natural refactor is to lift planBatches and sharedPromptOverhead into AbstractPrSuggestionGenerator, parameterised by the caller's prompt constants; respectPerRepoIgnores(...) should move up with them (it is wired only into /improve here 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-docs only ever inserts a doc comment above a declaration, so a quote that matches loosely is harmless. /improve rewrites the line, and committing a suggestion replaces the anchored range with suggestion_new verbatim. 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. /improve therefore 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 in suggestion_old so this path stays the common one rather than the fallback.

Configuration

New key thrillhousebot.review.improve-enabled / REVIEW_IMPROVE_ENABLED, documented in README.md and .env.example.

It defaults to true, matching the add-docs-enabled precedent 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_TOKENS and REVIEW_MAX_AI_CALLS. Their README rows now say so, and REVIEW_MAX_DIFF_LINES no longer lists /improve among 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; the 0.1.00.4.0 trees are frozen archives and are untouched. website/src/content/docs/index.md keeps 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 build in website/ passes with "All internal links are valid."

docs/COMPARISON.md was 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?

  • Unit tests
  • Integration tests
  • Manual testing

PrImprovementServiceTest (35 tests), ImprovementParserTest (6), ImprovementResponseTest (12), plus additions to SuggestionFormatterTest, TriggerDetectorTest, CommentCommandServiceTest and WebhookControllerTest. 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 IMPROVE entry from TriggerDetector.buildPatterns():

TriggerDetectorTest.shouldDetectEachSlashCommand:62 expected: IMPROVE but was: NONE
TriggerDetectorTest.shouldDetectEachMentionCommand:76 expected: IMPROVE but was: NONE
TriggerDetectorTest.shouldDetectImproveOutsideAQuotedMention:168 expected: IMPROVE but was: NONE

2. Command routing — removed the IMPROVE switch arm and the help-table row from CommentCommandService:

CommentCommandServiceTest.improveDelegatesToImprovementServiceWhenAuthorized
Wanted but not invoked:
improvementService.handle(
    ImproveTask[owner=owner, repo=repo, prNumber=7, defaultBranch=main, installationId=12345],
    "token"
);
Actually, there were zero interactions with this mock.

3. Generation / committable suggestions — made PrImprovementService.postInline return false unconditionally:

PrImprovementServiceTest.postsCommittableSuggestionForAnImprovement
Wanted but not invoked:
reviewClient.createPullRequestComment(any, any, "owner", "repo", 7,
    Capturing argument: CreatePullRequestCommentRequest);
Actually, there were zero interactions with this mock.

(also red: anchorsAMultiLineImprovementAcrossItsWholeRange, capsThePerRunCommentCount)

Round 2 — the indentation fix

Reverting the comparison to the original .strip() form, keeping the test:

PrImprovementServiceTest.doesNotRewriteALineWhoseQuoteDropsTheLeadingIndentation
org.mockito.exceptions.verification.NeverWantedButInvoked:

reviewClient.createPullRequestComment(
    any,
    any,
    any,
    any,
    any integer,
    any
);
Never wanted here:

That is the defect itself: with .strip() the mis-indented rewrite was posted as a one-click commit. Green once the fix is restored. anchorsAQuoteThatReproducesTheLeadingIndentation and anchorsDespiteInsignificantTrailingWhitespaceInTheDiff cover the other side, so the stricter rule cannot silently swallow correct suggestions.

Round 3 — coverage

codecov/patch was 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. with isPostable() stubbed to return true:

ImprovementResponseTest.anImprovementMissingRequiredDataIsNotPostable:76 null file ==> expected: false but was: true
ImprovementResponseTest.anImprovementMissingRequiredDataIsNotPostable:76 blank file ==> expected: false but was: true
ImprovementResponseTest.anImprovementMissingRequiredDataIsNotPostable:76 zero line ==> expected: false but was: 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 — sent inputs.diff() (the line-capped string) instead of the batch text:

org.opentest4j.AssertionFailedError:
[[THRILLHOUSEBOT-UNTRUSTED-DATA-3cc17bcc8c94fe51adb1bbe6ec3fcee4]]
## Overview: 2 files (+7 -0)


(diff truncated at 4 lines — 2 files omitted)

[[THRILLHOUSEBOT-UNTRUSTED-DATA-3cc17bcc8c94fe51adb1bbe6ec3fcee4]] ==> expected: <true> but was: <false>

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 sendsOneUncappedBatchWhenTokenBudgetingIsDisabled and leavesFilesTheRepositoryAskedTheBotToIgnoreOutOfScope.

splitsAnOversizedChangeSetAcrossBatchesAndDedupesTheResults — dropped the seen.add(dedupeKey(improvement)) guard:

org.mockito.exceptions.verification.TooManyActualInvocations:
reviewClient.createPullRequestComment(<any>, <any>, <any>, <any>, <any integer>, <any>);
Wanted 1 time:
But was 2 times:
-> at PrImprovementService.postInline(PrImprovementService.java:397)
-> at PrImprovementService.postInline(PrImprovementService.java:397)

neverSpendsMoreModelCallsThanMaxAiCallsmaxBatches() returning Integer.MAX_VALUE:

org.mockito.exceptions.verification.TooManyActualInvocations:
improveAssistant.improve(<any>, <any>, <any>, <any>);
Wanted 1 time:
But was 2 times:

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 the TruncationDetail:

org.opentest4j.AssertionFailedError:
✨ ThrillhouseBot has no improvements to suggest for the changes in this PR.

> ⚠️ **Large PR — partial coverage.** 2 file(s) were omitted because the diff exceeded the size budget, so this covers only part of the diff. ==> expected: <true> but was: <false>

sendsOneUncappedBatchWhenTokenBudgetingIsDisabled — the budgeting-off path planning with a 1-token diff budget instead of an uncapped one:

Wanted but not invoked:
improveAssistant.improve(<Capturing argument: String>, <any>, <any>, <any>);
Actually, there were zero interactions with this mock.

keepsImprovementsFromTheBatchesThatSucceededWhenOneBatchFailsif (failed == plan.batches().size()) weakened to if (failed > 0):

Wanted but not invoked:
reviewClient.createPullRequestComment(<any>, <any>, <any>, <any>, <any integer>, <any>);
Actually, there were zero interactions with this mock.

leavesFilesTheRepositoryAskedTheBotToIgnoreOutOfScoperespectPerRepoIgnores short-circuited to the global list:

org.opentest4j.AssertionFailedError:
[[THRILLHOUSEBOT-UNTRUSTED-DATA-293487720f3452d4b6d88f0c5795f471]]
## Overview: 2 files (+7 -0)

### src/Foo.java (modified, +4 -0)
...
### src/Other.java (modified, +3 -0)
...
==> expected: <false> but was: <true>

doesNotDiscloseTruncationForTheLineCapWhenTheBudgetCoveredEverything — re-appended the old ReviewResult.truncationDisclosure(inputs.omittedFiles()):

org.opentest4j.AssertionFailedError:
## ✨ ThrillhouseBot — suggested improvements

Proposed **1** committable improvement(s) inline on the changed lines.

---
*Nothing was committed — review each suggestion and commit the ones you want. Re-run with `/improve`.*

> ⚠️ **Large PR — partial coverage.** 48 file(s) were omitted because the diff exceeded the size budget, so this covers only part of the diff. ==> expected: <false> but was: <true>

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:

org.opentest4j.AssertionFailedError: expected: <✨ ThrillhouseBot found no reviewable changes to improve in this PR.> but was: <✨ ThrillhouseBot could not generate improvements for this PR. Please try `/improve` again.>

One mutation survived, and it should have. Narrowing the activeModel.maxInputTokens() <= 0 guard in planBatches to < 0 leaves the whole class green. That is correct rather than a hole: with budgeting off, perCallInputBudget() already returns Integer.MAX_VALUE, so the general path also produces one uncapped batch. The branch exists to skip the BPE pass entirely and to mark the plan budgeted=false, mirroring the identical guard in DiffBudgetPlanner.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 the plan(reviewable, 1, 1) mutation above and the line-capped-string mutation.

Batch budgets in these tests are derived from the real prompt overhead via TokenCounter rather 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:

PrImprovementServiceTest.anchorsAnImprovementFromALaterBatchToItsAbsoluteLine
Wanted but not invoked:
reviewClient.createPullRequestComment(<any>, <any>, "owner", "repo", 7,
    <Capturing argument: CreatePullRequestCommentRequest>);
Actually, there were zero interactions with this mock.

(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, but DiffLineResolver was still built from inputs.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 in handle(...) and threading it into both the planner and the resolver — the compute-once discipline ReviewContextLoader already uses. With the resolver reverted to inputs.reviewableFiles():

PrImprovementServiceTest.neverCommitsASuggestionToAFileTheRepositoryAskedTheBotToIgnore
org.mockito.exceptions.verification.NeverWantedButInvoked:

reviewClient.createPullRequestComment(<any>, <any>, <any>, <any>, <any integer>, <any>);
Never wanted here:
But invoked here:
-> at PrImprovementService.postInline(PrImprovementService.java:405) with arguments:
   [..., CreatePullRequestCommentRequest[commitId=headsha1234567, body=**✨ Improvement — Bound the retry loop** `error-handling`
   ...
   ```suggestion
   while (retries++ < 3) { call(); }
   ```
   , path=src/Other.java, line=2, side=RIGHT, startLine=null, startSide=null]]

src/Other.java is 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 — the current != null arm of quotesCurrentLine. 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: postInline only calls it after resolveRightSideLine returned the exact line, and DiffLineResolver.appendRightSide does lineText.put(...) unconditionally for every line it adds to lines. It is kept as fail-closed defence against a future divergence rather than deleted to buy a percentage point. DiffBudgetPlanner is 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_old at all. Committing a suggestion replaces the anchored line range with the block body verbatim, so it cannot "fail to apply" — suggestion_old is purely this codebase's own anchor.

The conclusion is right for a different reason, and it is a real defect: DiffLineResolver stores right-side text as rawLine.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:

return current != null
    && current.stripTrailing().equals(improvement.suggestionOld().stripTrailing());

🔵 LOW — "Missing newline after opening code fence in formatImprovementBlock": wrong. Patch not applied; it would have introduced the defect it describes.

CODE_FENCE_CLOSE was "\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):

SuggestionFormatterTest.shouldRenderImprovementBlockFencesByteExactly
expected: **T**\n\n```\nline one\nline two\n```\n
but was:  **T**\n\n```\n\nline one\nline two\n\n```\n

— a blank line as the first and last line inside every code block, in /add-docs notes as well as /improve blocks.

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_CLOSE is renamed to CODE_FENCE with 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.files might 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.files calls GitHubPullRequestClient.getPullRequestFiles, which walks pages of FILES_PER_PAGE = 100 up to MAX_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: /improve inside fenced code blocks, blockquotes and inline code is asserted not to trigger the command, while a genuine /improve alongside 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 String values, not by the tests still passing; S135 — the loop's continue removed by inverting the guard).

Local gates: ./mvnw -B spotless:apply clean, ./mvnw -B clean compile spotbugs:check spotless:check reports BugInstance size is 0, ./mvnw -B clean test reports Tests run: 2112, Failures: 0, Errors: 0, Skipped: 0.

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

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:

[[THRILLHOUSEBOT-UNTRUSTED-DATA-3cc17bcc8c94fe51adb1bbe6ec3fcee4]]
## Overview: 2 files (+7 -0)


(diff truncated at 4 lines — 2 files omitted)

[[THRILLHOUSEBOT-UNTRUSTED-DATA-3cc17bcc8c94fe51adb1bbe6ec3fcee4]]

Additional Notes

PrDescriptionGeneratorTest and ChangelogEntryGeneratorTest each needed a two-line stub update: loadInputs now calls the two-argument buildDiffStringWithStats(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:

  • feat(review): move /describe, /changelog and /add-docs off the line cap onto token-budgeted batching #457 — see The seam #457 builds on above for the two methods to lift and what is already shared.
  • The multi-line anchoring path still matches on stripped lines via 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.
  • An improvement naming a repo-ignored file still renders as a copy-paste block in the summary (it just cannot be committed with one click). The block contains only model-authored text and the path it invented, never content from the ignored file, so this is the same treatment any unanchorable suggestion gets.

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

This change adds an on-demand /improve command that runs a whole-PR improvement pass over the diff and posts committable suggestions or copy-paste blocks inline on the PR, reusing the existing suggestion infrastructure and input loading.

Control-Flow Diagram

🔀 Show diagram
flowchart 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"]
Loading

Changes Overview

  • Files changed: 21
  • Lines added: +1205
  • Lines removed: -24

Changed Files

File Change Summary
.env.example Modified Add commented example for REVIEW_IMPROVE_ENABLED.
README.md Modified Document new /improve command and config key; update on-demand command list.
docs/ARCHITECTURE.md Modified -
src/main/java/dev/thiagogonzaga/thrillhousebot/config/ThrillhouseConfig.java Modified New improve-enabled config key defaulting to true, providing kill switch for the /improve command.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/AbstractPrSuggestionGenerator.java Modified Extend Inputs record with headSha and reviewableFiles; adjust loadInputs to collect and pass them.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/PrImprovementService.java Added Handle /improve command: load diff with truncation, call AI, post inline suggestions or copy-paste blocks with per-run comment cap.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/SuggestionFormatter.java Modified Add formatting methods for improvement inline comments and copy-paste blocks.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/ImprovementParser.java Added -
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/ImprovementResponse.java Added Record to hold parsed improvements with a postable check that filters out incomplete entries.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/PrImproveAssistant.java Added LangChain4j AI service interface for the /improve pass, grounded in the same user prompt as other suggestion commands.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/PrImproveAssistantPrompts.java Added System prompt instructing the model to propose concrete, committable improvements across the diff.
src/main/java/dev/thiagogonzaga/thrillhousebot/webhook/CommentCommand.java Modified Add IMPROVE enum value.
src/main/java/dev/thiagogonzaga/thrillhousebot/webhook/CommentCommandService.java Modified Route /improve command with authorization, pause check, and enabled guard; delegate to PrImprovementService.
src/main/java/dev/thiagogonzaga/thrillhousebot/webhook/TriggerDetector.java Modified Add patterns for /improve and @mention form; tests confirm quotation contexts are excluded.
src/main/resources/application.properties Modified Bind REVIEW_IMPROVE_ENABLED to improve-enabled config with default true.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ChangelogEntryGeneratorTest.java Modified -
src/test/java/dev/thiagogonzaga/thrillhousebot/review/PrDescriptionGeneratorTest.java Modified -
src/test/java/dev/thiagogonzaga/thrillhousebot/review/PrImprovementServiceTest.java Added 12 tests covering inline anchoring, copy-paste fallback, truncation disclosure, comment cap, no-diff, and failure cases.
src/test/java/dev/thiagogonzaga/thrillhousebot/webhook/CommentCommandServiceTest.java Modified -
src/test/java/dev/thiagogonzaga/thrillhousebot/webhook/TriggerDetectorTest.java Modified -

…and 1 more file(s).

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

@thrillhousebot thrillhousebot Bot added documentation Improvements or additions to documentation enhancement New feature or request java Pull requests that update java code labels Aug 8, 2026
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.64413% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ga/thrillhousebot/review/PrImprovementService.java 99.51% 0 Missing and 1 partial ⚠️

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
return sb.append(FOOTER).append(ReviewResult.truncationDisclosure(omittedFiles)).toString();
return current == null || current.stripTrailing().equals(improvement.suggestionOld().stripTrailing());

Comment thread src/main/java/dev/thiagogonzaga/thrillhousebot/review/SuggestionFormatter.java Outdated
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

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

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

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

/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

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

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 /improve command uses SoftLoaders.files to 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 existing SoftLoaders.files does 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 that SoftLoaders.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

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

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

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

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.

@sonarqubecloud

sonarqubecloud Bot commented Aug 8, 2026

Copy link
Copy Markdown

@devops-thiago
devops-thiago deleted the feat/316-improve-command branch August 8, 2026 17:03
devops-thiago added a commit that referenced this pull request Aug 8, 2026
/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
devops-thiago added a commit that referenced this pull request Aug 8, 2026
## 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.
devops-thiago added a commit that referenced this pull request Aug 9, 2026
…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.**
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 java Pull requests that update java code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant