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 @@ -70,6 +70,10 @@ GITHUB_WEBHOOK_SECRET=your_webhook_secret
# Comma-separated gitignore-style globs excluded from review (lockfiles, generated code, build
# output). '*' does not cross '/'; use '**' to span directories:
#THRILLHOUSEBOT_REVIEW_IGNORED_FILES=**/pom.xml,**/package-lock.json,**/*.lock,**/*.generated.*,**/target/**
# Let each repository add ignore globs of its own under review.ignored-files in
# .github/thrillhousebot.yml. They are unioned with the list above β€” a repo can skip more, never
# less. Set false to make the deployment list the only one that counts:
#THRILLHOUSEBOT_REVIEW_REPO_CONFIG_ENABLED=true

# Optional: context-aware PR labels (off by default). Enable, then choose apply vs. suggest-only.
#REVIEW_LABELS_ENABLED=true
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

- **Per-repo ignore patterns** (#51): a repository can now declare ignore globs of its own in an optional `.github/thrillhousebot.yml`, under `review.ignored-files`. They are **additive** β€” the effective set is the union of the deployment-wide `thrillhousebot.review.ignored-files` list and whatever the repository declared, so a repo can take more files out of review scope (generated dirs, vendored code, fixtures) but can never put back a file the deployment excludes. One install can therefore serve repositories with different layouts without everyone sharing a single global list. The file is read from the default branch and cached per repository for five minutes, alongside the existing instructions lookup, and fails soft in every direction: a missing file, invalid YAML, an unexpected shape, or an uncompilable glob is logged and skipped, leaving the global list in force. Operators who do not want repositories adjusting their own review scope can disable the mechanism with `THRILLHOUSEBOT_REVIEW_REPO_CONFIG_ENABLED=false`

## [0.5.0] β€” 2026-07-26

Review precision: confidence now decides where a finding lands, newly-added parsers and regexes are stress-tested for their own failure modes, and several classes of false positive are guarded at both the generator and the verifier. Operators gain configurable CI-gating and blocking strictness, structured skip reasons, and per-model generation parameters.
Expand Down
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ guide, configuration reference, architecture, comparison, and the hosted
- OpenTelemetry traces, token histograms, cost counters, and latency metrics
- Optional reasoning-effort dial and per-model generation/budget caps for OpenAI-compatible endpoints
- Reads per-repo instructions from `.github/thrillhousebot.md`, falling back to Copilot/Claude/Agents files
- Lets each repository add its own ignore globs in `.github/thrillhousebot.yml`, unioned with the deployment default
- Compiles ahead-of-time with GraalVM/Mandrel, so it starts fast and stays small
<!-- docs:features:end -->

Expand Down Expand Up @@ -258,6 +259,7 @@ will change per provider:
| `THRILLHOUSEBOT_REVIEW_AI_TIMEOUT_SECONDS` | Client-side wait per AI streaming attempt; keep it >= `AI_TIMEOUT` so timed-out attempts don't leave orphaned provider streams | `300` |
| `THRILLHOUSEBOT_REVIEW_INSTRUCTIONS_FILE` | Repo-relative path of the per-repo instructions file read on each review | `.github/thrillhousebot.md` |
| `THRILLHOUSEBOT_REVIEW_IGNORED_FILES` | Comma-separated gitignore-style globs excluded from review β€” lockfiles, generated code, build output. `*` does not cross `/`; use `**` to span directories. Replaces (not extends) the default list, so re-include the defaults you still want | `**/pom.xml,**/package-lock.json,**/*.lock,**/*.generated.*,**/target/**` |
| `THRILLHOUSEBOT_REVIEW_REPO_CONFIG_ENABLED` | Let each repository extend the ignore list with globs of its own from `.github/thrillhousebot.yml` (see [Repository configuration](#repository-configuration)). Per-repo globs are additive; set `false` to make the deployment list the only one that counts | `true` |
| `REVIEW_LABELS_ENABLED` | Opt in to context-aware PR labels (see [PR labels](#pr-labels)) | `false` |
| `REVIEW_LABELS_APPLY` | When labels are enabled, add them to the PR instead of only suggesting them in a comment | `false` |
| `REVIEW_LABELS_ALLOW_CREATE` | Allow the bot to create suggested labels that don't exist yet | `false` |
Expand Down Expand Up @@ -420,6 +422,40 @@ Place a `.github/thrillhousebot.md` file in any repo to customize the review:

Fallback chain: `.github/thrillhousebot.md` β†’ `.github/copilot-instructions.md` β†’ `CLAUDE.md` β†’ `AGENTS.md` β†’ `AGENT.md`

<!-- docs:repository-configuration:start -->
## Repository configuration

The instructions file (`.github/thrillhousebot.md`) is prose for the model.
Structured settings live in a separate, optional `.github/thrillhousebot.yml`
(`.github/thrillhousebot.yaml` also works) β€” kept apart on purpose, because the
instructions fallback chain may land on a file owned by another tool, and its whole
content is fed to the model as untrusted prose:

```yaml
review:
# Extra paths this repository never wants reviewed, on top of the deployment default.
ignored-files:
- "docs/generated/**"
- "**/*.snap"
- "testdata/**"
```

**Precedence: the effective ignore list is the union of both β€” global βˆͺ per-repo.**
A file is skipped if it matches *either* the deployment-wide
`thrillhousebot.review.ignored-files` list *or* a glob the repository declared. A
repository can therefore take more files out of review scope, but never put back a
file the deployment excludes, and a repository that ships no config file gets the
global list exactly as before. Globs use the same gitignore-style syntax as the
global key (`*` does not cross `/`; use `**` to span directories).

The file is read from the repository's default branch on each review and cached for
five minutes. Everything about it fails soft: a missing file, invalid YAML, an
unexpected shape, or an uncompilable glob is logged and skipped, leaving the global
list in force β€” it never fails a review. Operators who do not want repositories
adjusting their own review scope can turn the whole mechanism off with
`THRILLHOUSEBOT_REVIEW_REPO_CONFIG_ENABLED=false`.
<!-- docs:repository-configuration:end -->

<!-- docs:pr-labels:start -->
## PR labels

Expand Down Expand Up @@ -541,7 +577,9 @@ This is still an early-stage project; the current constraints are:
sourcemaps, generated code (`*.generated.*`, protobuf output), and build or
vendor directories (`target/`, `node_modules/`, `dist/`, `build/`, `out/`,
`.next/`, `vendor/`, `__pycache__/`, `.venv/`, `bin/`, `obj/`) are skipped by
default (`thrillhousebot.review.ignored-files`, overridable per deployment).
default (`thrillhousebot.review.ignored-files`, overridable per deployment, and
extendable per repository via `.github/thrillhousebot.yml` β€” see
[Repository configuration](#repository-configuration)).
- **Self-hosted** β€” no managed offering from this project.

## Verifying a release
Expand Down
9 changes: 9 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId>
</dependency>
<!--
YAML reading for a repository's own .github/thrillhousebot.yml. Already on the
classpath transitively (via quarkus-smallrye-openapi) and version-managed by the
jackson-bom import above; declared explicitly because it is now used directly.
-->
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,18 @@ interface ReviewConfig {
@WithName("ignored-files")
List<String> ignoredFiles();

/**
* Whether a repository may extend {@link #ignoredFiles()} with globs of its own, declared under
* {@code review.ignored-files} in {@code .github/thrillhousebot.yml}. Per-repo patterns are
* additive β€” the effective set is always global βˆͺ per-repo, so a repository can take more files
* out of review scope but can never put back a file the deployment excludes. This is the
* operator's kill switch for installs that must not let a repository narrow its own review
* coverage; a missing or malformed file is ignored either way and never fails a review.
*/
@WithDefault("true")
@WithName("repo-config-enabled")
boolean repoConfigEnabled();

/**
* GitHub logins permitted to manually trigger reviews regardless of repository permission. When
* empty, only users with write access to the repository may trigger a manual review.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 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.github;

import java.util.List;

/**
* The structured settings a repository declares for itself, read from {@code
* .github/thrillhousebot.yml} by {@link RepoSettingsResolver}.
*
* <p>This is the one place per-repo <em>structured</em> settings live β€” deliberately separate from
* the prose instructions file ({@link InstructionsResolver}), whose fallback chain reaches into
* files owned by other tools and whose content is fed to the model as untrusted prose. New
* structured settings are added as components here and parsed in {@link RepoSettingsParser}; every
* one of them must degrade to its {@link #EMPTY} value rather than fail a review.
*
* @param ignoredFiles extra ignore globs, unioned with (never replacing) the deployment-wide {@code
* thrillhousebot.review.ignored-files} list
* @param source the repo-relative path the settings were read from, or {@code "none"}
*/
public record RepoSettings(List<String> ignoredFiles, String source) {

/** No per-repo settings β€” the deployment defaults apply unchanged. */
public static final RepoSettings EMPTY = new RepoSettings(List.of(), "none");

public RepoSettings {
ignoredFiles = List.copyOf(ignoredFiles);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* 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.github;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.yaml.snakeyaml.LoaderOptions;

/**
* Parses the YAML in a repository's {@code .github/thrillhousebot.yml} into {@link RepoSettings}.
*
* <p>Shape (every key optional):
*
* <pre>{@code
* review:
* ignored-files:
* - "docs/generated/**"
* - "**''/''*.snap"
* }</pre>
*
* <p>The file is untrusted input from an arbitrary repository, so parsing is deliberately
* defensive: it reads a generic tree rather than binding to a POJO (no reflection, no type
* coercion), bounds the document with snakeyaml loader limits, caps how many patterns a repository
* may contribute, and returns {@link RepoSettings#EMPTY} for anything it cannot make sense of. It
* never throws β€” a malformed config must degrade to "no per-repo settings", never fail a review.
*/
final class RepoSettingsParser {

private static final Logger log = LoggerFactory.getLogger(RepoSettingsParser.class);

/** Ceiling on the YAML document size, guarding against an oversized or hostile config. */
private static final int MAX_CODE_POINTS = 256 * 1024;

/** Ceiling on YAML nesting, guarding against deeply nested documents. */
private static final int MAX_NESTING_DEPTH = 20;

/** Ceiling on anchor/alias expansion, guarding against "billion laughs"-style blowups. */
private static final int MAX_ALIASES = 50;

/** Ceiling on how many extra ignore globs one repository may contribute. */
static final int MAX_PATTERNS = 200;

/** Ceiling on a single glob's length β€” a pathological pattern is dropped, not compiled. */
static final int MAX_PATTERN_LENGTH = 512;

private static final ObjectMapper YAML_MAPPER = new ObjectMapper(yamlFactory());

private RepoSettingsParser() {}

private static YAMLFactory yamlFactory() {
var options = new LoaderOptions();
options.setCodePointLimit(MAX_CODE_POINTS);
options.setNestingDepthLimit(MAX_NESTING_DEPTH);
options.setMaxAliasesForCollections(MAX_ALIASES);
options.setAllowDuplicateKeys(false);
return YAMLFactory.builder().loaderOptions(options).build();
}

/**
* Parses {@code yaml}, attributing the result to {@code source} (the repo-relative path it came
* from). Returns {@link RepoSettings#EMPTY} for blank, malformed, or setting-less content.
*/
static RepoSettings parse(String yaml, String source) {
if (yaml == null || yaml.isBlank()) {
return RepoSettings.EMPTY;
}
try {
// Pattern match rather than isObject(): one test rejects a scalar or sequence document
// (which carries no settings) and a null/missing root alike.
if (!(YAML_MAPPER.readTree(yaml) instanceof ObjectNode root)) {
log.warn("Repository config {} is not a YAML mapping; ignoring it", source);
return RepoSettings.EMPTY;
}
var ignoredFiles = readPatterns(root.path("review").path("ignored-files"), source);
return ignoredFiles.isEmpty() ? RepoSettings.EMPTY : new RepoSettings(ignoredFiles, source);
} catch (IOException | RuntimeException e) {
log.warn(
"Could not parse repository config {}; continuing with the global settings only",
source,
e);
return RepoSettings.EMPTY;
}
}

/**
* Reads {@code review.ignored-files} as a list of globs. A sequence of scalars is the documented
* form; a single scalar is also accepted and split on commas, matching how the global key is
* written as an environment variable. Anything else is ignored.
*/
private static List<String> readPatterns(JsonNode node, String source) {
return switch (node.getNodeType()) {
// path() yields a MissingNode when the key is absent, and `ignored-files:` with no value
// yields a NullNode. Both mean the repository declared nothing β€” not a malformed config.
case MISSING, NULL -> List.of();
case ARRAY -> sanitize(scalarEntries(node), source);
// A lone scalar is split on commas, matching how the global key is written as an env var.
case STRING -> sanitize(List.of(node.asText().split(",")), source);
default -> {
log.warn("Repository config {}: review.ignored-files is not a list; ignoring it", source);
yield List.of();
}
};
}

/** The scalar entries of a sequence; a nested mapping or sequence entry is not a glob. */
private static List<String> scalarEntries(JsonNode array) {
var raw = new ArrayList<String>(array.size());
for (var element : array) {
if (element.isValueNode()) {
raw.add(element.asText());
}
}
return raw;
}

/** Trims, drops blank/oversized entries, and caps the total a repository may contribute. */
private static List<String> sanitize(List<String> raw, String source) {
var patterns = new ArrayList<String>(Math.min(raw.size(), MAX_PATTERNS));
for (String value : raw) {

Check warning on line 139 in src/main/java/dev/thiagogonzaga/thrillhousebot/github/RepoSettingsParser.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Reduce the total number of break and continue statements in this loop to use at most one.

See more on https://sonarcloud.io/project/issues?id=devops-thiago_ThrillhouseBot&issues=AZ_e19tCbel9KZ2LI-ln&open=AZ_e19tCbel9KZ2LI-ln&pullRequest=449
// Never null: entries come from asText() (empty string at worst) or String.split.
var pattern = value.trim();
if (pattern.isEmpty()) {
continue;
}
if (pattern.length() > MAX_PATTERN_LENGTH) {
log.warn("Repository config {}: dropping over-long ignore pattern", source);
continue;
}
if (patterns.size() >= MAX_PATTERNS) {
log.warn(
"Repository config {}: more than {} ignore patterns; using the first {}",
source,
MAX_PATTERNS,
MAX_PATTERNS);
break;
}
patterns.add(pattern);
}
return List.copyOf(patterns);
}
}
Loading
Loading