Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ GITHUB_WEBHOOK_SECRET=your_webhook_secret
# Optional: include an opt-in Mermaid control-flow diagram in the PR summary (set to true to enable)
#REVIEW_DIAGRAM_ENABLED=false

# Optional: post a short delta comment (new / resolved / still-open counts) on follow-up reviews
# (set to true to enable); a follow-up pass with no delta still posts nothing
#REVIEW_FOLLOW_UP_SUMMARY_ENABLED=false

# Optional: token budgeting for whole-PR review (multi-call map-reduce); defaults shown.
# MAX_INPUT_TOKENS=0 disables budgeting; MAX_AI_CALLS caps batch calls + the final summary call.
# The effective budget is additionally capped by the active model's max-input-tokens entry
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ will change per provider:
| `REVIEW_ADD_DOCS_ENABLED` | Allow the on-demand `/add-docs` command to generate docstrings as committable suggestions | `true` |
| `REVIEW_IMPROVE_ENABLED` | Allow the on-demand `/improve` command to run a whole-PR improvement pass and post committable suggestions | `true` |
| `REVIEW_DIAGRAM_ENABLED` | Include an opt-in Mermaid control-flow diagram in the PR summary | `false` |
| `REVIEW_FOLLOW_UP_SUMMARY_ENABLED` | Post a short delta comment on follow-up reviews with the new-finding, resolved, and still-open counts. Only the first review posts the full summary; a follow-up pass with no delta (nothing new, nothing resolved) posts nothing | `false` |
| `REVIEW_MAX_INPUT_TOKENS` | Per-call input-token budget for review and `/improve` calls; large PRs are split into batches that each fit it. Bounded by the active model's input cap (see [Per-model AI settings](#per-model-ai-settings)). `0` disables token budgeting | `48000` |
| `REVIEW_OUTPUT_BUFFER_TOKENS` | Tokens reserved out of the input budget for the model's response | `8192` |
| `REVIEW_MAX_AI_CALLS` | Cap on AI calls per review (batch calls plus the final summary call) and per `/improve` run (batch calls only β€” its summary is assembled locally); files that still don't fit are reported by name as omitted | `6` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,23 @@ static String normalizeCiGating(String raw) {
LabelsConfig labels();

DiagramConfig diagram();

@WithName("follow-up-summary")
FollowUpSummaryConfig followUpSummary();
}

/**
* Opt-in delta summary comment on follow-up reviews. The first review posts the full PR summary;
* every later pass normally carries its signal in the review itself, so a maintainer on a busy PR
* has to read the inline threads to see what moved. When {@link #enabled()} a follow-up pass that
* actually changed something also posts a short comment with the new-finding, resolved, and
* still-open counts. Off by default β€” the quiet follow-up is the released behaviour β€” and skipped
* whenever the round has no delta, so enabling it never adds a per-push comment.
*/
interface FollowUpSummaryConfig {
/** Master switch β€” no delta comment is rendered or posted unless this is {@code true}. */
@WithDefault("false")
boolean enabled();
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Copyright 2026 Thiago Gonzaga
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dev.thiagogonzaga.thrillhousebot.review;

import java.util.Optional;

/**
* Renders the short "what moved since the last pass" comment posted on a follow-up review when
* {@code thrillhousebot.review.follow-up-summary.enabled} is on.
*
* <p>Deliberately <em>not</em> a second render method on {@link PrSummaryGenerator}: that class
* assembles the 500-line first-review summary from the model's summary object, the PR-level diff
* stats, the walkthrough table and the optional diagram, and is gated on its own config. This
* comment needs none of that β€” it is a pure function of the {@link ReviewResult} counts β€” so
* folding it in would mean threading unrelated inputs through, and would put two different comment
* shapes behind one entry point. Keeping it separate also keeps {@link
* PrSummaryGenerator#SUMMARY_HEADING} out of the rendered body, which matters: that heading is the
* marker {@code ReviewContextLoader.isBotSummaryComment} uses to recognize the bot's summary, and a
* delta comment carrying it would be mistaken for one β€” edited in place on a superseded round, and
* counted as an already-posted summary when deciding whether a review is the first.
*
* <p>Three counts are rendered, all sourced from the statuses the follow-up pipeline already
* produced rather than recomputed here: findings raised this round, previous findings the round
* closed, and previous findings still open. A {@code justified} status (declined by a maintainer)
* is in none of them β€” it is neither newly fixed nor still open β€” and {@code superseded} is not
* counted either: it is an auto-close because the targeted code left the diff, not something the
* round fixed. A superseded round also re-posts the full summary, and the caller skips this comment
* whenever that re-post lands.
*
* <p>The counts are only as good as the previous-finding statuses handed to them: issue #455
* records that a round returning zero findings corrupts the previous-findings context, which can
* both drop a real finding out of tracking and inflate the still-open count. That defect is tracked
* separately; the guard below limits the blast radius here, since a round whose only "movement" is
* a phantom carry-over renders nothing at all.
*/
final class FollowUpDeltaSummary {

/**
* First line of the delta comment. Distinct from {@link PrSummaryGenerator#SUMMARY_HEADING} on
* purpose β€” see the class javadoc.
*/
static final String DELTA_HEADING = "## πŸ€– ThrillhouseBot β€” changes since the last review";

private FollowUpDeltaSummary() {}

/**
* The delta comment body, or {@link Optional#empty()} when this round has no delta to report.
*
* <p>"Delta" means the round moved something: it raised at least one finding, or it closed at
* least one previous finding. Previous findings that merely stayed open are reported inside a
* comment that posts for one of those reasons, but never trigger one on their own β€” a pass that
* re-states the same open count is exactly the per-push noise this feature must not add, and it
* is also the shape a miscounted carry-over takes, which is better left un-amplified.
*/
static Optional<String> render(ReviewResult result) {
var newFindings = result.totalFindings();
var resolved = result.resolvedPreviousCount();
if (newFindings == 0 && resolved == 0) {
return Optional.empty();
}
var body =
DELTA_HEADING
+ "\n\n"
+ "- **New findings this round:** "
+ newFindings
+ "\n"
+ "- **Previous findings resolved:** "
+ resolved
+ "\n"
+ "- **Previous findings still open:** "
+ result.unresolvedPreviousCount()
+ "\n"
// Same partial-coverage wording the on-demand commands use: these counts cover only the
// reviewed portion of a truncated diff, so the comment has to say so.
+ ReviewResult.truncationDisclosure(result.omittedFiles());
return Optional.of(body);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@ public boolean review(ReviewRequest request) {
String checkTitle = VerdictBuilder.checkTitleForResult(result);
String checkSummary = VerdictBuilder.checkSummaryForResult(result);
boolean summaryPosted = publishSummaryBestEffort(auth, req, result);
// Opt-in follow-up delta comment. Runs only when no summary was posted this round, and its
// outcome is intentionally discarded β€” it must not feed summaryPosted below.
publishFollowUpDeltaBestEffort(auth, req, result, summaryPosted);
reviewPublisher.dismissPendingBotReviews(
auth, req.owner(), req.repo(), req.prNumber(), priorReviews);
// summaryPosted gates the redundant-review skips: a failed summary post leaves review
Expand Down Expand Up @@ -362,6 +365,28 @@ private boolean publishSummaryBestEffort(String auth, ReviewRequest req, ReviewR
}
}

/**
* Posts the opt-in follow-up delta comment, swallowing any failure for the same reason {@link
* #publishSummaryBestEffort} does: it is enrichment, not the review, so a transient failure here
* must not abort before {@code postReview}. The result is not returned β€” the delta comment never
* stands in for the review, so it can never gate the redundant-review skips.
*/
private void publishFollowUpDeltaBestEffort(
String auth, ReviewRequest req, ReviewResult result, boolean summaryPosted) {
try {
reviewPublisher.publishFollowUpDelta(
auth, req.owner(), req.repo(), req.prNumber(), result, summaryPosted);
} catch (RuntimeException e) {
Log.warnf(
e,
"Failed to post the follow-up delta comment for %s/%s #%d β€” continuing to post the"
+ " review",
req.owner(),
req.repo(),
req.prNumber());
}
}

/**
* Resolves the CI evaluation for a request: the required-context lookup unioned across rulesets
* and classic protection, then the per-check evaluation on the head commit. Runs off the review
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,49 @@ boolean publishSummary(
return false;
}

/**
* Posts the opt-in follow-up delta comment β€” the short "what moved since the last pass" note
* described by {@link FollowUpDeltaSummary}. Independent of {@link #publishSummary}: it never
* runs on a first review, and never on a round that did post a summary comment, so the two can
* not both land on the same review and the first-run summary is never duplicated.
*
* <p>Its outcome deliberately does <em>not</em> feed the {@code summaryPosted} flag {@link
* #postReview} keys its redundant-review skips on. That flag means "the PR already carries this
* round's verdict in a comment"; a delta comment carries counts, not a verdict, so letting it
* suppress the review would leave a clean follow-up with no stated outcome at all.
*
* @param summaryPosted whether {@link #publishSummary} created or refreshed a summary comment on
* this round β€” when it did, this comment is skipped rather than posted beside it
* @return {@code true} when the delta comment was created; {@code false} when the feature is off,
* the review is a first review, a summary was posted, or the round has no delta to report
*/
boolean publishFollowUpDelta(
String auth,
String owner,
String repo,
int prNumber,
ReviewResult result,
boolean summaryPosted) {
if (!config.review().followUpSummary().enabled() || result.isFirstReview() || summaryPosted) {
return false;
}
var body = FollowUpDeltaSummary.render(result);
if (body.isEmpty()) {
Log.debugf(
"No delta to report for %s/%s #%d β€” skipping the follow-up summary comment",
owner, repo, prNumber);
return false;
}
commentClient.createComment(
auth,
ACCEPT,
owner,
repo,
prNumber,
new GitHubCommentClient.CreateCommentRequest(body.get()));
return true;
}

/**
* Replaces the stale summary with the regenerated one after a finding was superseded: edits the
* bot's newest existing summary comment in place, so the PR never shows the outdated summary
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,16 @@ public long unresolvedPreviousCount() {
return previousStatuses.stream().filter(s -> "unresolved".equalsIgnoreCase(s.status())).count();
}

/**
* How many previous findings this round closed as fixed. Strictly the {@code resolved} status:
* {@code justified} is a maintainer's decline, not a fix, and {@code superseded} is an auto-close
* because the targeted code left the diff β€” counting either as "resolved" would overstate what
* the round actually fixed.
*/
public long resolvedPreviousCount() {
return previousStatuses.stream().filter(s -> "resolved".equalsIgnoreCase(s.status())).count();
}

// A backstop-held finding may have no inline thread (its line was outside the diff when raised),
// hence the "where one exists" qualifier.
public static String unresolvedPreviousMessage(long unresolved) {
Expand Down
5 changes: 5 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ thrillhousebot.review.labels.max-labels=${REVIEW_LABELS_MAX:3}
# block in the summary comment. Rides the existing review call (a few extra output tokens).
thrillhousebot.review.diagram.enabled=${REVIEW_DIAGRAM_ENABLED:false}

# Delta summary comment on follow-up reviews (opt-in). The first review posts the full PR summary;
# when enabled, a later pass that actually changed something also posts a short comment with the
# new-finding, resolved, and still-open counts. A pass with no delta posts nothing.
thrillhousebot.review.follow-up-summary.enabled=${REVIEW_FOLLOW_UP_SUMMARY_ENABLED:false}

# Database (H2 for dev, PostgreSQL for prod)
quarkus.datasource.db-kind=${DATASOURCE_DB_KIND:h2}
quarkus.datasource.jdbc.url=jdbc:h2:mem:thrillhouse;DB_CLOSE_DELAY=-1
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2026 Thiago Gonzaga
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dev.thiagogonzaga.thrillhousebot.config;

import static org.junit.jupiter.api.Assertions.assertFalse;

import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

/**
* Default profile: the follow-up delta summary is off, so an untouched deployment keeps the quiet
* follow-up review it has today and never gains a per-push comment. Asserted through the resolved
* configuration, not the {@code @WithDefault} annotation, so the {@code
* thrillhousebot.review.follow-up-summary.enabled} property wiring is covered too.
*/
@QuarkusTest
class FollowUpSummaryDefaultOffTest {

@Inject ThrillhouseConfig config;

@Test
void followUpSummaryIsOffByDefault() {
assertFalse(config.review().followUpSummary().enabled());
}
}
Loading
Loading