Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ GITHUB_WEBHOOK_SECRET=your_webhook_secret
# (set to false to disable)
#REVIEW_GENERATE_TESTS_ENABLED=true

# Optional: let /describe REPLACE the PR's title and body with the generated suggestion instead of
# only posting it as a comment (set to true to enable). The previous title and description are
# preserved in the bot's confirmation comment, and only write-authorized users can trigger it.
#REVIEW_DESCRIBE_APPLY=false

# Optional: include an opt-in Mermaid control-flow diagram in the PR summary (set to true to enable)
#REVIEW_DIAGRAM_ENABLED=false

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to ThrillhouseBot.

## [Unreleased]

### Added

- **Opt-in apply mode for `/describe`** (#325): with `REVIEW_DESCRIBE_APPLY=true`, a write-authorized `/describe` replaces the PR's title and body with the generated suggestion instead of only posting it as a comment. Off by default β€” suggest-only stays the released behaviour. The confirmation comment preserves the previous title and description so the overwrite is never destructive, the apply is logged for audit, and a run whose output doesn't parse or whose GitHub update fails falls back to the plain suggestion comment

## [0.6.2] β€” 2026-08-14

Follow-ups to the review threads on 0.6.1, plus the first piece of the release
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ not a reaction.
| `/help` | List the available commands | anyone |
| `/review` | Run (or re-run) a full review of the PR | write |
| `/summary` | Post the PR summary if it isn't already on the PR β€” regenerates it if the comment was deleted, otherwise no-op | write |
| `/describe` | Suggest an improved PR title and description generated from the diff, as a comment to copy in (never overwrites the PR) | write |
| `/describe` | Suggest an improved PR title and description generated from the diff, as a comment to copy in. Never overwrites the PR by default; a deployment that opts in with `REVIEW_DESCRIBE_APPLY=true` has it replace the PR's title and body instead | write |
| `/changelog` | Draft a CHANGELOG entry for the PR from the diff (Added/Changed/Fixed/Security…), as a comment to copy into `CHANGELOG.md` (never commits) | write |
| `/add-docs` | Generate docstrings/inline docs for the symbols changed in the PR, posted as committable suggestions (or a note with the drafted docs when a multi-line declaration can't be pinned to a single diff hunk) | write |
| `/improve` | Run a whole-PR improvement pass over the diff and post the improvements as committable suggestions (with copy-paste blocks for the ones that can't be pinned to the diff) | write |
Expand Down Expand Up @@ -130,6 +130,16 @@ extra model call, reserved out of `REVIEW_MAX_AI_CALLS` and spent only when the
more than one batch, so a run never exceeds the same ceiling as one review. Any file the budget
could not cover is named in a partial-coverage note under the suggestion.

**`/describe` apply mode (opt-in)** β€” by default `/describe` only posts a suggestion comment
and never touches the PR. A deployment that sets `REVIEW_DESCRIBE_APPLY=true` changes that:
`/describe` then **replaces the PR's title and body** with the generated suggestion, so enable
it only where maintainers expect the bot to edit their PRs. The edit is gated twice β€” it needs
that explicit config *and* a commenter who passes the same write-access check as every command β€”
and it is never destructive: the confirmation comment the bot posts carries the previous title
and description, so anything replaced stays recoverable on the PR. When the model output doesn't
parse into a title and description, or the GitHub update fails, the run falls back to posting the
plain suggestion comment instead of editing the PR.

**`/add-docs`** β€” on demand, the bot reads the diff and proposes documentation comments for
the public symbols changed in the PR, honoring the repository instructions and each file's
language. Each suggestion is a committable `suggestion` block placed on the symbol's
Expand Down Expand Up @@ -305,6 +315,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_GENERATE_TESTS_ENABLED` | Allow the on-demand `/generate-tests` command to propose unit tests for the changed code | `true` |
| `REVIEW_DESCRIBE_APPLY` | ⚠️ Opt-in auto-edit: make `/describe` **replace the PR's title and body** with the generated suggestion instead of only posting it as a comment (see [Commands](#commands)). The previous title and description are preserved in the confirmation comment, and the edit still requires a write-authorized commenter | `false` |
| `REVIEW_DIAGRAM_ENABLED` | Include an opt-in Mermaid control-flow diagram in the PR summary | `false` |
| `REVIEW_PATCH_COVERAGE_ENABLED` | Feed patch coverage into the review context: the added lines the repository's own coverage report records as never executed (see [Repository configuration](#repository-configuration)). Only takes effect for a repository that names its coverage artifact in `.github/thrillhousebot.yml` | `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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,8 @@ static String normalizeCiGating(String raw) {

LabelsConfig labels();

DescribeConfig describe();

DiagramConfig diagram();

@WithName("patch-coverage")
Expand Down Expand Up @@ -516,6 +518,22 @@ interface LabelsConfig {
int maxLabels();
}

/**
* Opt-in apply mode for the {@code /describe} command, mirroring {@link LabelsConfig#apply()}:
* suggestion-only is the released behaviour, and letting the bot mutate the PR is the operator's
* explicit call.
*/
interface DescribeConfig {
/**
* When {@code true}, a {@code /describe} from a write-authorized user replaces the PR's title
* and body with the generated suggestion; the previous title and body are preserved in the
* confirmation comment, so the overwrite is never destructive. When {@code false} (the
* default), the suggestion is only posted as a comment to copy in and the PR is never edited.
*/
@WithDefault("false")
boolean apply();
}

interface DashboardConfig {
/** Public base URL of the dashboard, used for session deep-links posted to GitHub. */
@WithName("url")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,38 @@ default PullRequestDetails getPullRequest(
credential -> getPullRequestOnce(credential, accept, owner, repo, pullNumber));
}

/** One HTTP attempt at editing a PR. Callers want {@link #updatePullRequest} instead. */
@PATCH
@Path("/repos/{owner}/{repo}/pulls/{pullNumber}")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
PullRequestDetails updatePullRequestOnce(
@HeaderParam("Authorization") String auth,
@HeaderParam("Accept") String accept,
@PathParam("owner") String owner,
@PathParam("repo") String repo,
@PathParam("pullNumber") int pullNumber,
UpdatePullRequestRequest request);

/**
* Replaces a PR's title and body β€” the opt-in {@code /describe} apply path β€” with the same
* throttle backoff every other GitHub write gets. Losing this write to a throttle would post a
* confirmation comment describing an edit that never happened, so it retries like the comment
* writes do rather than only healing a rejected credential.
*/
default PullRequestDetails updatePullRequest(
String auth,
String accept,
String owner,
String repo,
int pullNumber,
UpdatePullRequestRequest request) {
return GitHubWriteRetry.DEFAULT.call(
"an update of the title/body of PR " + owner + "/" + repo + "#" + pullNumber,
auth,
credential -> updatePullRequestOnce(credential, accept, owner, repo, pullNumber, request));
}

/** One HTTP attempt at a files page. Callers want {@link #getPullRequestFilesPage} instead. */
@GET
@Path("/repos/{owner}/{repo}/pulls/{pullNumber}/files")
Expand Down Expand Up @@ -194,6 +226,29 @@ public Ref(String sha) {
}
}

/** GitHub's hard maximum PR-title length, in characters; a longer title is rejected with 422. */
int TITLE_MAX_LENGTH = 256;

/**
* Body of the PR update PATCH. Only the title and body fields are sent, so nothing else about the
* PR (state, base, …) can change. Both fields are capped to the limit GitHub enforces for them,
* like every other outgoing text field: the body with the shared truncation notice, the title
* with a plain cut because the multi-line notice cannot go in a single-line field.
*/
record UpdatePullRequestRequest(String title, String body) {
public UpdatePullRequestRequest {
if (title != null && title.length() > TITLE_MAX_LENGTH) {
int keep = TITLE_MAX_LENGTH;
// Never leave a dangling high surrogate at the cut point.
if (Character.isHighSurrogate(title.charAt(keep - 1))) {
keep--;
}
title = title.substring(0, keep);
}
body = CommentBodyLimit.cap(body);
}
}

record FileDiff(
String filename,
String status, // added, modified, removed, renamed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@
import jakarta.inject.Inject;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import org.eclipse.microprofile.rest.client.inject.RestClient;

/**
* Builds the {@code /describe} suggestion: an improved PR title and description generated from the
* diff, posted as a comment the author may copy in. It never edits the pull request, so the
* author's own title and body are never overwritten.
* diff, posted as a comment the author may copy in. By default it never edits the pull request, so
* the author's own title and body are never overwritten; a deployment that opts in with {@code
* thrillhousebot.review.describe.apply=true} instead has the caller apply the suggestion to the PR,
* for which {@link #generateSuggestion} also parses the title and description out of the model
* output and prepares a confirmation comment that preserves what was replaced.
*
* <p>Loads the PR's current title/body and diff and the repository instructions (via {@link
* AbstractPrSuggestionGenerator}), asks the {@link PrDescribeAssistant} for a suggestion, then
Expand Down Expand Up @@ -78,6 +82,28 @@ public class PrDescriptionGenerator extends AbstractPrSuggestionGenerator {
description. Re-run with `/describe`.*
""";

static final String APPLIED_HEADER = "## πŸ€– ThrillhouseBot β€” PR title & description updated\n\n";

static final String APPLIED_FOOTER =
"""


---
*Applied by `/describe` β€” this deployment opts in with \
`thrillhousebot.review.describe.apply=true`. The previous title and description are \
preserved above. Edit the PR to adjust, or re-run `/describe` after more changes.*
""";

/**
* The two required sections of the model's answer, in the exact shape the prompts demand. The
* title line's wrapping backticks (and a stray blank line before it) are tolerated and stripped,
* because the title goes into the PR's single-line title field verbatim.
*/
private static final Pattern SUGGESTION_SECTIONS =
Pattern.compile(
"###\\s+Suggested title\\s*\\R+(.+?)\\R+\\s*###\\s+Suggested description\\s*\\R+(.+)",
Pattern.DOTALL);

private final PrDescribeAssistant describeAssistant;

@Inject
Expand All @@ -101,6 +127,21 @@ public PrDescriptionGenerator(
this.describeAssistant = describeAssistant;
}

/**
* Everything one {@code /describe} run produced. {@code suggestBody} is the suggestion comment of
* the default suggest-only path. {@code title}, {@code description} and {@code applyBody} serve
* the opt-in apply path: the parsed pieces to PATCH onto the PR, and the confirmation comment β€”
* carrying the replaced title and body β€” to post once the PATCH succeeded. All three are {@code
* null} when the model output did not parse into the two required sections (or when there was
* nothing to describe at all), leaving the suggestion comment as the only thing to post.
*/
public record Suggestion(String title, String description, String suggestBody, String applyBody) {
/** Whether this suggestion parsed into pieces the apply path can put on the PR. */
public boolean applicable() {
return title != null && description != null && applyBody != null;
}
}

/**
* Generates the suggestion comment body for a PR, or {@code null} when there is nothing to
* suggest (no diff) or the model produced no usable answer. The caller is responsible for posting
Expand All @@ -117,6 +158,32 @@ public String generate(
String defaultBranch,
long installationId,
String auth) {
var suggestion = doGenerate(owner, repo, prNumber, defaultBranch, installationId, auth);
return suggestion == null ? null : suggestion.suggestBody();
}

/**
* The {@link #generate} run with its pieces kept apart, for the opt-in apply path. Same contract:
* {@code null} when there is nothing to suggest or no usable answer came back.
*/
@ActivateRequestContext
public Suggestion generateSuggestion(
String owner,
String repo,
int prNumber,
String defaultBranch,
long installationId,
String auth) {
return doGenerate(owner, repo, prNumber, defaultBranch, installationId, auth);
}

private Suggestion doGenerate(
String owner,
String repo,
int prNumber,
String defaultBranch,
long installationId,
String auth) {
var inputs = loadInputs(owner, repo, prNumber, defaultBranch, installationId, auth, COMMAND);
if (inputs == null) {
return null;
Expand All @@ -139,7 +206,9 @@ public String generate(
// Nothing fitted, but the files that did not are known: name them rather than go quiet. A
// plan that covered nothing and omitted nothing means no file was in scope at all (every one
// ignored), which is genuinely nothing to say.
return plan.truncated() ? NOT_COVERED + disclosure(plan) : null;
return plan.truncated()
? new Suggestion(null, null, NOT_COVERED + disclosure(plan), null)
: null;
}
var drafted = describeEachBatch(inputs, plan);
if (drafted.partials().isEmpty()) {
Expand All @@ -149,12 +218,82 @@ public String generate(
if (suggestion == null) {
return null;
}
return HEADER
+ suggestion
+ batchFailureNote(
drafted.failedBatches(), COMMAND, "the files in them are not described here.")
+ FOOTER
+ disclosure(plan);
var note =
batchFailureNote(
drafted.failedBatches(), COMMAND, "the files in them are not described here.");
var suggestBody = HEADER + suggestion + note + FOOTER + disclosure(plan);
var parsed = parseSections(suggestion);
if (parsed == null) {
return new Suggestion(null, null, suggestBody, null);
}
var applyBody =
APPLIED_HEADER + previousContent(inputs) + note + APPLIED_FOOTER + disclosure(plan);
return new Suggestion(parsed.title(), parsed.description(), suggestBody, applyBody);
}

/** The parsed sections of a well-formed answer; see {@link #parseSections}. */
record TitleAndDescription(String title, String description) {}

/**
* Extracts the proposed title and description from the model's answer, or {@code null} when the
* answer does not carry both sections in the demanded shape. Only the apply path needs this β€” a
* suggestion comment posts the answer as-is β€” so a shape the parser cannot read degrades the run
* to suggest-only rather than failing it.
*/
static TitleAndDescription parseSections(String suggestion) {
if (suggestion == null) {
return null;
}
var matcher = SUGGESTION_SECTIONS.matcher(suggestion);
if (!matcher.find()) {
return null;
}
var title =
matcher
.group(1)
.lines()
.map(String::strip)
.filter(line -> !line.isEmpty())
.findFirst()
.orElse("");
if (title.startsWith("`")) {
// A title opening with a backtick is only readable as one wrapping pair. Anything else β€” an
// opener with no close on the line, a multi-backtick wrapper, a bare ``` fence line β€” would
// be applied verbatim, backticks and all, so those degrade to suggest-only instead. (A title
// merely *ending* in an inline code span, like "fix: guard `null`", stays accepted: only an
// opening backtick makes the line read as a wrapper.)
if (title.length() < 2 || !title.endsWith("`")) {
return null;
}
String inner = title.substring(1, title.length() - 1).strip();
if (inner.isEmpty() || inner.startsWith("`") || inner.endsWith("`")) {
return null;
}
title = inner;
}
var description = matcher.group(2).strip();
if (title.isEmpty() || description.isEmpty()) {
return null;
}
return new TitleAndDescription(title, description);
}

/**
* The replaced title and body, collapsed into the confirmation comment so the apply overwrite is
* never destructive: whatever `/describe` replaced stays recoverable on the PR itself.
*/
private static String previousContent(Inputs inputs) {
var title = inputs.title() == null || inputs.title().isBlank() ? "_(none)_" : inputs.title();
var body =
inputs.body() == null || inputs.body().isBlank() ? "_(no description)_" : inputs.body();
return "The title and description of this pull request were replaced with the suggestion"
+ " ThrillhouseBot generated from the diff.\n\n"
+ "<details>\n<summary>Previous title and description</summary>\n\n"
+ "**Title:** "
+ title
+ "\n\n"
+ body
+ "\n\n</details>";
}

/** The per-batch partial descriptions that came back, plus how many batch calls failed. */
Expand Down
Loading