Skip to content

Commit 2609ad6

Browse files
committed
perf(review): exclude pure renames from AI review budget (#386) (#411)
- [ ] 🐛 Bug fix - [ ] ✨ Feature - [x] 📝 Documentation - [ ] 🔧 Refactor - [x] 🚀 Performance - [x] ✅ Test - [ ] 🔒 Security - [ ] 📦 Dependency update - [ ] 🏗️ CI/CD Implements [#386](#386). Pure renames (`status=renamed`, `additions+deletions==0`, blank/null patch) are excluded from `reviewableFiles()` so they never enter `DiffBudgetPlanner` or line-capped diff sections. Rename+edit (non-empty patch) stays reviewable. The summary / diff overview discloses a capped rollup (`N pure renames omitted…`) and pure renames do **not** count toward truncation / APPROVE hold. Also maps GitHub’s `previous_filename` onto `FileDiff` for `old → new` samples in the rollup. Fixes #386 - [x] Unit tests - [ ] Integration tests - [ ] Manual testing ```bash ./mvnw -Dtest=ReviewDiffFormatterTest,DiffBudgetPlannerTest,ReviewContextLoaderTest test ``` 118 tests, 0 failures. Updated `shouldHandleFilesWithZeroChanges` for the new rollup behavior. - [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 N/A Targets `release/v0.5.0` (milestone candidate). Signed-off-by: Thiago Gonzaga <thiago.gonzaga@icloud.com>
1 parent 6619bad commit 2609ad6

9 files changed

Lines changed: 242 additions & 13 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,10 @@ This is still an early-stage project; the current constraints are:
514514
don't fit are disclosed by name instead of silently dropped. The on-demand commands
515515
(`/describe`, `/changelog`, `/add-docs`) still send the diff in a single call without
516516
batching.
517+
- **Pure renames** — files GitHub reports as `renamed` with zero additions/deletions and
518+
no patch are omitted from AI review input (they have nothing to review). The summary
519+
overview still lists a short rollup (`N pure renames omitted…`). Rename-plus-edit
520+
(non-empty patch) stays in the budget.
517521
- **Single process** — OAuth login sessions, the live WebSocket replay buffer, and
518522
the per-PR auto-review rate-limit window are in-memory (lost on restart / not shared
519523
across replicas). Review history and cost totals persist in PostgreSQL.

src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubPullRequestClient.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,15 @@ record FileDiff(
118118
int additions,
119119
int deletions,
120120
int changes,
121-
String patch) {}
121+
String patch,
122+
@JsonProperty("previous_filename") String previousFilename) {
123+
124+
/** Convenience overload when the rename source path is unused. */
125+
public FileDiff(
126+
String filename, String status, int additions, int deletions, int changes, String patch) {
127+
this(filename, status, additions, deletions, changes, patch, null);
128+
}
129+
}
122130

123131
record CompareResponse(@JsonProperty("total_commits") int totalCommits, List<FileDiff> files) {
124132
public CompareResponse {

src/main/java/dev/thiagogonzaga/thrillhousebot/review/FindingPipeline.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,12 @@ private String findingsJson(List<ReviewResponse.Finding> findings) {
545545
private static String changedFilesOverview(
546546
ReviewContextLoader.ReviewContext ctx, DiffBudgetPlanner.BudgetPlan plan) {
547547
var sb = new StringBuilder();
548+
// Pure-rename rollup first so clampOverview (keeps a prefix) never drops the disclosure on
549+
// large multi-call reviews (#386).
550+
var pureRenames = ReviewDiffFormatter.pureRenameFiles(ctx.files());
551+
if (!pureRenames.isEmpty()) {
552+
sb.append(ReviewDiffFormatter.formatPureRenameRollup(pureRenames));
553+
}
548554
var omitted = Set.copyOf(plan.omittedFiles());
549555
var clipped = Set.copyOf(plan.clippedFiles());
550556
for (var file : ctx.reviewableFiles()) {

src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewDiffFormatter.java

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,94 @@ private static boolean matchesSuffix(PathMatcher suffix, Path path) {
119119
return false;
120120
}
121121

122-
/** Files that are included in AI review scope (non-ignored). */
122+
/**
123+
* Pure rename: GitHub reports {@code status=renamed} with no content hunks. These burn AI budget
124+
* without anything to review — exclude them from model input (see #386). Rename+edit (non-empty
125+
* patch and/or non-zero add/del) is still reviewable.
126+
*/
127+
static boolean isPureRename(GitHubPullRequestClient.FileDiff file) {
128+
if (file == null || file.status() == null) {
129+
return false;
130+
}
131+
if (!"renamed".equalsIgnoreCase(file.status())) {
132+
return false;
133+
}
134+
if (file.additions() + file.deletions() != 0) {
135+
return false;
136+
}
137+
return file.patch() == null || file.patch().isBlank();
138+
}
139+
140+
/** Pure renames in {@code files}, preserving order. */
141+
static List<GitHubPullRequestClient.FileDiff> pureRenameFiles(
142+
List<GitHubPullRequestClient.FileDiff> files) {
143+
if (files == null || files.isEmpty()) {
144+
return List.of();
145+
}
146+
return files.stream().filter(ReviewDiffFormatter::isPureRename).toList();
147+
}
148+
149+
/**
150+
* One-line disclosure for the summary / diff overview. Caps the path sample so bulk package moves
151+
* do not dominate the prompt.
152+
*/
153+
static String formatPureRenameRollup(List<GitHubPullRequestClient.FileDiff> pureRenames) {
154+
if (pureRenames == null || pureRenames.isEmpty()) {
155+
return "";
156+
}
157+
final int sampleCap = 5;
158+
var samples = new ArrayList<String>(Math.min(sampleCap, pureRenames.size()));
159+
for (var i = 0; i < pureRenames.size() && samples.size() < sampleCap; i++) {
160+
var file = pureRenames.get(i);
161+
var prev = file.previousFilename();
162+
if (prev != null && !prev.isBlank()) {
163+
samples.add(prev + " → " + file.filename());
164+
} else {
165+
samples.add(file.filename());
166+
}
167+
}
168+
var sb = new StringBuilder();
169+
sb.append(pureRenames.size())
170+
.append(pureRenames.size() == 1 ? " pure rename" : " pure renames")
171+
.append(" omitted from AI review (")
172+
.append(String.join(", ", samples));
173+
var more = pureRenames.size() - samples.size();
174+
if (more > 0) {
175+
sb.append(", and ").append(more).append(" more");
176+
}
177+
sb.append(")\n");
178+
return sb.toString();
179+
}
180+
181+
/** Files that are included in AI review scope (non-ignored, non–pure-rename). */
123182
List<GitHubPullRequestClient.FileDiff> reviewableFiles(
124183
List<GitHubPullRequestClient.FileDiff> files) {
125184
if (files == null || files.isEmpty()) {
126185
return List.of();
127186
}
128-
return files.stream().filter(f -> !isIgnored(f.filename())).toList();
187+
return files.stream()
188+
.filter(f -> !isIgnored(f.filename()))
189+
.filter(f -> !isPureRename(f))
190+
.toList();
191+
}
192+
193+
/**
194+
* Reviewable files plus pure renames — for prompt context that should still see moved paths
195+
* (related-tests list, summary walkthrough counts) without putting empty rename hunks in the
196+
* model diff.
197+
*/
198+
static List<GitHubPullRequestClient.FileDiff> withPureRenames(
199+
List<GitHubPullRequestClient.FileDiff> reviewable,
200+
List<GitHubPullRequestClient.FileDiff> allFiles) {
201+
var renames = pureRenameFiles(allFiles);
202+
if (renames.isEmpty()) {
203+
return reviewable;
204+
}
205+
var merged =
206+
new ArrayList<GitHubPullRequestClient.FileDiff>(reviewable.size() + renames.size());
207+
merged.addAll(reviewable);
208+
merged.addAll(renames);
209+
return merged;
129210
}
130211

131212
/**
@@ -181,10 +262,19 @@ FormattedDiff buildDiffStringWithStats(
181262
totalDeletions += file.deletions();
182263
}
183264

265+
var pureRenames = pureRenameFiles(files);
184266
var header =
185-
String.format(
186-
"## Overview: %d files (+%d -%d)%n%n", files.size(), totalAdditions, totalDeletions);
187-
return formatWithLineBudget(header, files, namesOf(reviewableFiles));
267+
new StringBuilder(
268+
String.format(
269+
"## Overview: %d files (+%d -%d)%n%n",
270+
files.size(), totalAdditions, totalDeletions));
271+
if (!pureRenames.isEmpty()) {
272+
header.append(formatPureRenameRollup(pureRenames)).append('\n');
273+
}
274+
// Pure renames are disclosed in the rollup only — do not emit empty ### headers into the
275+
// model input (or count them toward the line-budget truncation / APPROVE hold).
276+
var sectionFiles = files.stream().filter(f -> !isPureRename(f)).toList();
277+
return formatWithLineBudget(header.toString(), sectionFiles, namesOf(reviewableFiles));
188278
}
189279

190280
static Set<String> namesOf(List<GitHubPullRequestClient.FileDiff> files) {

src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewPromptAssembler.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ AiReviewService.PromptInputs assemble(
5858
// The diagram request's presence is what gates the model's walkthrough_diagram field.
5959
String diagramGuidance =
6060
config.review().diagram().enabled() ? PrReviewPrompts.DIAGRAM_REQUEST : "";
61-
String relatedTests = diffFormatter.buildRelatedTests(ctx.reviewableFiles());
61+
// Include pure-renamed test files so mock-fidelity / related-tests guidance still sees moves
62+
// even though empty rename hunks are excluded from the reviewable diff (#386).
63+
String relatedTests =
64+
diffFormatter.buildRelatedTests(
65+
ReviewDiffFormatter.withPureRenames(ctx.reviewableFiles(), ctx.files()));
6266
String trailingGuidance =
6367
combineSections(
6468
combineSections(

src/main/java/dev/thiagogonzaga/thrillhousebot/review/VerdictBuilder.java

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,16 @@ ReviewResult build(
123123
? plan.omittedFiles().size() + plan.clippedFiles().size()
124124
: ctx.omittedFiles();
125125
// GitHub PR-level totals when available; ignore-glob drops can undercount diff-derived stats.
126+
// Pure renames are excluded from reviewableFiles for AI budget (#386) but still belong in the
127+
// fallback file count / walkthrough when PR totals could not be fetched.
128+
var overviewFiles = overviewFiles(ctx);
126129
var diffStats =
127-
DiffStats.fromFiles(ctx.reviewableFiles(), omitted, truncation)
130+
DiffStats.fromFiles(overviewFiles, omitted, truncation)
128131
.withAuthoritativeTotals(ctx.prTotals());
129132
var omittedNames = Set.copyOf(truncation.omittedFileNames());
130133
var changedFiles =
131134
toChangedFiles(
132-
ctx.reviewableFiles().stream()
133-
.filter(f -> !omittedNames.contains(f.filename()))
134-
.toList());
135+
overviewFiles.stream().filter(f -> !omittedNames.contains(f.filename())).toList());
135136
// A model-reported "unresolved" whose targeted code left the diff (force-push) becomes
136137
// "superseded" before the gates run, so a vanished finding never holds APPROVE (#336).
137138
// Skip the DiffLineResolver when there are no statuses — first reviews and empty-status
@@ -296,6 +297,24 @@ static DiffStats fromFiles(
296297
}
297298
}
298299

300+
/**
301+
* Reviewable files plus pure renames — the fallback set for Changes Overview counts when GitHub
302+
* PR totals are unavailable. Pure renames are omitted from AI input but still part of the PR.
303+
*/
304+
static List<GitHubPullRequestClient.FileDiff> overviewFiles(
305+
ReviewContextLoader.ReviewContext ctx) {
306+
var pureRenames = ReviewDiffFormatter.pureRenameFiles(ctx.files());
307+
if (pureRenames.isEmpty()) {
308+
return ctx.reviewableFiles();
309+
}
310+
var merged =
311+
new ArrayList<GitHubPullRequestClient.FileDiff>(
312+
ctx.reviewableFiles().size() + pureRenames.size());
313+
merged.addAll(ctx.reviewableFiles());
314+
merged.addAll(pureRenames);
315+
return merged;
316+
}
317+
299318
/**
300319
* Projects the reviewed diff onto the (path, change type) rows the summary walkthrough renders.
301320
*/

src/test/java/dev/thiagogonzaga/thrillhousebot/review/DiffBudgetPlannerTest.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,20 @@ void aModelCapNeverReenablesExplicitlyDisabledBudgeting() {
324324
when(reviewConfig.maxInputTokens()).thenReturn(0);
325325
assertEquals(Integer.MAX_VALUE, planner.perCallInputBudget());
326326
}
327+
328+
@Test
329+
void mixedPrPackingPrefersRealDiffsAfterPureRenamesAreFilteredOut() {
330+
// Mirrors the orchestrator path: reviewableFiles() drops pure renames before plan().
331+
var pure = new FileDiff("moved/B.java", "renamed", 0, 0, 0, null, "moved/A.java");
332+
var real = file("src/App.java", 8, patch(8));
333+
var reviewable = formatter.reviewableFiles(List.of(pure, real));
334+
335+
assertEquals(List.of("src/App.java"), reviewable.stream().map(FileDiff::filename).toList());
336+
337+
when(reviewConfig.maxAiCalls()).thenReturn(3);
338+
var plan = planner.plan(reviewable, sectionTokens(real) + 10, 2);
339+
340+
assertEquals(List.of("src/App.java"), coveredFilenames(plan));
341+
assertTrue(plan.omittedFiles().isEmpty(), "pure rename must not appear as budget omission");
342+
}
327343
}

src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewContextLoaderTest.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,17 @@ void shouldAccumulateTotalsAcrossMultipleFiles() {
144144
void shouldHandleFilesWithZeroChanges() {
145145
var files =
146146
List.of(
147-
new GitHubPullRequestClient.FileDiff("renamed-only.txt", "renamed", 0, 0, 0, null));
147+
new GitHubPullRequestClient.FileDiff(
148+
"renamed-only.txt", "renamed", 0, 0, 0, null, "old-name.txt"));
148149

149150
var result = diffFormatter.buildDiffString(files);
150151

151152
assertTrue(result.contains("## Overview: 1 files (+0 -0)"));
152-
assertTrue(result.contains("renamed-only.txt (renamed, +0 -0)"));
153+
// Pure renames are disclosed in a rollup, not as empty ### sections (#386).
154+
assertTrue(
155+
result.contains(
156+
"1 pure rename omitted from AI review (old-name.txt → renamed-only.txt)"));
157+
assertFalse(result.contains("### renamed-only.txt"));
153158
}
154159
}
155160

src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewDiffFormatterTest.java

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,4 +680,81 @@ void isTestFileShouldRejectNullAndNonTestNames() {
680680
assertTrue(ReviewDiffFormatter.isTestFile("data_test.csv"));
681681
assertTrue(ReviewDiffFormatter.isTestFile("native/FooTest.kt"));
682682
}
683+
684+
@Nested
685+
class PureRenameExclusion {
686+
687+
@Test
688+
void isPureRenameRequiresRenamedStatusZeroDiffAndBlankPatch() {
689+
assertTrue(
690+
ReviewDiffFormatter.isPureRename(
691+
new GitHubPullRequestClient.FileDiff("b.java", "renamed", 0, 0, 0, null, "a.java")));
692+
assertTrue(
693+
ReviewDiffFormatter.isPureRename(
694+
new GitHubPullRequestClient.FileDiff("b.java", "RENAMED", 0, 0, 0, " ", "a.java")));
695+
// Rename + content edit must stay reviewable.
696+
assertFalse(
697+
ReviewDiffFormatter.isPureRename(
698+
new GitHubPullRequestClient.FileDiff(
699+
"b.java", "renamed", 1, 0, 1, "@@ -1 +1,2 @@\n+x", "a.java")));
700+
assertFalse(
701+
ReviewDiffFormatter.isPureRename(
702+
new GitHubPullRequestClient.FileDiff("b.java", "modified", 0, 0, 0, null)));
703+
}
704+
705+
@Test
706+
void reviewableFilesSkipsPureRenamesButKeepsRenamePlusEdit() {
707+
var formatter = new ReviewDiffFormatter(List.of(), 5000);
708+
var pure =
709+
new GitHubPullRequestClient.FileDiff(
710+
"pkg/B.java", "renamed", 0, 0, 0, null, "pkg/A.java");
711+
var edited =
712+
new GitHubPullRequestClient.FileDiff(
713+
"pkg/D.java", "renamed", 2, 0, 2, "@@ -1 +1,3 @@\n+y\n+z", "pkg/C.java");
714+
var modified = file("src/App.java", "modified", 1, 0, "@@ -1 +1,2 @@\n+ok");
715+
716+
var reviewable = formatter.reviewableFiles(List.of(pure, edited, modified));
717+
718+
assertEquals(2, reviewable.size());
719+
assertEquals("pkg/D.java", reviewable.get(0).filename());
720+
assertEquals("src/App.java", reviewable.get(1).filename());
721+
}
722+
723+
@Test
724+
void buildDiffStringDisclosesPureRenamesWithoutEmittingEmptySections() {
725+
var formatter = new ReviewDiffFormatter(List.of(), 5000);
726+
var files =
727+
List.of(
728+
new GitHubPullRequestClient.FileDiff(
729+
"new/Name.java", "renamed", 0, 0, 0, null, "old/Name.java"),
730+
file("src/App.java", "modified", 1, 0, "@@ -1 +1,2 @@\n+ok"));
731+
732+
var result = formatter.buildDiffStringWithStats(files);
733+
734+
assertEquals(0, result.omittedFiles(), "pure renames must not count as truncation");
735+
assertTrue(
736+
result
737+
.text()
738+
.contains("1 pure rename omitted from AI review (old/Name.java → new/Name.java)"));
739+
assertFalse(result.text().contains("### new/Name.java"));
740+
assertTrue(result.text().contains("### src/App.java"));
741+
}
742+
743+
@Test
744+
void pureRenameRollupCapsSampleAndReportsRemainder() {
745+
var renames = new java.util.ArrayList<GitHubPullRequestClient.FileDiff>();
746+
for (var i = 0; i < 7; i++) {
747+
renames.add(
748+
new GitHubPullRequestClient.FileDiff(
749+
"n" + i + ".java", "renamed", 0, 0, 0, null, "o" + i + ".java"));
750+
}
751+
752+
var rollup = ReviewDiffFormatter.formatPureRenameRollup(renames);
753+
754+
assertTrue(rollup.startsWith("7 pure renames omitted from AI review ("));
755+
assertTrue(rollup.contains("and 2 more"));
756+
assertTrue(rollup.contains("o0.java → n0.java"));
757+
assertFalse(rollup.contains("o5.java"));
758+
}
759+
}
683760
}

0 commit comments

Comments
 (0)