Skip to content

feat(review): support per-repo ignore patterns alongside the global default - #449

Merged
devops-thiago merged 2 commits into
release/v0.6.0from
feat/51-per-repo-ignores
Aug 8, 2026
Merged

feat(review): support per-repo ignore patterns alongside the global default#449
devops-thiago merged 2 commits into
release/v0.6.0from
feat/51-per-repo-ignores

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

thrillhousebot.review.ignored-files is app-wide, so a single deployment reviewing many repositories has to pick one list for all of them. This lets a repository declare ignore globs of its own, unioned with (never replacing) the global default.

Where per-repo structured settings live — and why

Decision: a dedicated .github/thrillhousebot.yml (with .github/thrillhousebot.yaml as an alternate name), not frontmatter in the existing .github/thrillhousebot.md.

  • The instructions fallback chain deliberately reaches into files owned by other tools (.github/copilot-instructions.md, CLAUDE.md, AGENTS.md, AGENT.md). Frontmatter would mean writing ThrillhouseBot config into whichever of those happened to win — and which one wins varies per repository.
  • The instructions file's whole content is handed to the model as untrusted prose. Frontmatter would either leak config noise into the prompt or require stripping it first, changing the prompt bytes for every repository that already has an instructions file.
  • A dedicated file is structured, versionable, and independent of the prompt path.

This is a substrate for #33 (path-scoped review instructions), which will need per-repo structured settings from the same place. The seam is:

Note that #33 is about review rules per path, whereas this controls what is sent to the model at all; they stay distinct.

Shape

review:
  ignored-files:
    - "docs/generated/**"
    - "**/*.snap"
    - "testdata/**"

Precedence: global ∪ per-repo. A file is skipped if it matches either list. A repository can take more files out of review scope, never put back a file the deployment excludes.

Implementation notes

  • One glob matcher, not two. The existing GlobMatcher / compileGlobMatchers / **-suffix behaviour in ReviewDiffFormatter is now wrapped in an IgnoreGlobs value type that both the global list and the per-repo list compile through, so a repository can never get different matching semantics than the deployment default. union() is the additive operation.
  • The glob is still walked once per review. ReviewDiffFormatter is @ApplicationScoped with its patterns fixed at construction, so per-repo patterns could not be baked in. Instead the effective IgnoreGlobs is resolved once in ReviewContextLoader.load(...) and threaded into the single reviewableFiles(...) call (and the base comparison), preserving the existing compute-once property.
  • Cache: RepoSettingsResolver mirrors InstructionsResolver — 5-minute TTL, 1-minute negative cache, size-triggered sweep, LongSupplier clock for tests.
  • Fail-soft at every layer: feature off → EMPTY; file absent → next name, then EMPTY; transport error → EMPTY; undecodable content → EMPTY; malformed YAML or unexpected shape → EMPTY (RepoSettingsParser never throws); uncompilable glob → dropped by the existing compileGlobMatchers catch; and SoftLoaders.repoSettings(...) is the outer guarantee.
  • Untrusted input hardening: the parser reads a generic tree (no reflection, no POJO binding) and bounds the document with snakeyaml loader limits (code-point, nesting depth, alias expansion, duplicate keys), plus caps on pattern count and pattern length.
  • New config key thrillhousebot.review.repo-config-enabled (default true) is the operator kill switch, following the add-docs-enabled precedent. Documented in README.md and .env.example.
  • No new dependency: jackson-dataformat-yaml was already on the compile classpath via quarkus-smallrye-openapi and version-managed by the existing jackson-bom import; it is now declared explicitly in pom.xml because it is used directly. No version was added or bumped.

Deliberately out of scope

The on-demand commands (/describe, /changelog, /add-docs, maintainer replies) still use the global list only — they build their diffs through separate call paths, and MaintainerReplyService has no default branch in its task to resolve the config with. Extending them is mechanical now that the seam exists (diffFormatter.ignoreGlobs(settings.ignoredFiles()) plus the reviewableFiles(files, globs) overload) and is best done as a follow-up rather than widening this diff.

Related Issues

Fixes #51

Related: #33 (path-scoped review instructions) will build on RepoSettings / RepoSettingsParser / RepoSettingsResolver.

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

New tests: RepoSettingsResolverTest (17 cases: parsing, .yml.yaml fallback, caps, fail-soft, TTL / negative cache / sweep, disabled flag), ReviewDiffFormatterTest$PerRepoIgnorePatterns (5 cases), and 3 end-to-end load(...) cases in ReviewContextLoaderTest.

Each new behaviour was validated red/green. With the tests in the tree, the production behaviour was neutralized while keeping the API (so the tests still compiled and ran, rather than failing to build): ReviewDiffFormatter.ignoreGlobs(...) reduced to return globalGlobs;, ReviewContextLoader.load reverted to diffFormatter.reviewableFiles(files), and RepoSettingsResolver.resolve short-circuited to RepoSettings.EMPTY.

Red phase — actual failures produced:

[ERROR] ReviewDiffFormatterTest.repoDeclaredPatternTakesEffect
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>

[ERROR] ReviewDiffFormatterTest.effectiveSetIsTheUnionOfGlobalAndPerRepoPatterns
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>

[ERROR] ReviewDiffFormatterTest.malformedRepoPatternIsDroppedWithoutFailingTheReview
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>

[ERROR] ReviewDiffFormatterTest.perRepoPatternsAlsoScopeTheBaseComparison
org.opentest4j.AssertionFailedError:
  (expected "(test/fixtures/big.json skipped: matches ignored pattern" in the rendered comparison)

[ERROR] ReviewContextLoaderTest.perRepoIgnorePatternsNarrowTheReviewableFileSet
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>

[ERROR] RepoSettingsResolverTest.readsIgnoredFilesFromTheYmlFile
org.opentest4j.AssertionFailedError: expected: <[docs/generated/**, **/*.snap]> but was: <[]>

[ERROR] RepoSettingsResolverTest.fallsBackToTheYamlExtension
org.opentest4j.AssertionFailedError: expected: <[vendored/**]> but was: <[]>

[ERROR] RepoSettingsResolverTest.acceptsACommaSeparatedScalarLikeTheEnvVarForm
org.opentest4j.AssertionFailedError: expected: <[docs/generated/**, **/*.snap]> but was: <[]>

[ERROR] RepoSettingsResolverTest.capsHowManyPatternsARepositoryMayContribute
org.opentest4j.AssertionFailedError: expected: <200> but was: <0>

[ERROR] RepoSettingsResolverTest.dropsBlankAndOverLongPatterns
org.opentest4j.AssertionFailedError: expected: <[kept/**]> but was: <[]>

[ERROR] RepoSettingsResolverTest.cachesPerRepositoryAndRefetchesAfterTheTtl
org.opentest4j.AssertionFailedError: expected: <[gen/**]> but was: <[]>

[ERROR] RepoSettingsResolverTest.cachesAreKeyedPerRepository
org.opentest4j.AssertionFailedError: expected: <[gen/**]> but was: <[]>

[ERROR] RepoSettingsResolverTest.negativeResultIsCachedBrieflyThenRetried
Wanted but not invoked:
prClient.getFileContent("Bearer test-jwt", "application/vnd.github+json", "test-owner",
    "test-repo", ".github/thrillhousebot.yml", "main");
Actually, there were zero interactions with this mock.

[ERROR] RepoSettingsResolverTest.sweepDropsExpiredEntriesOnceTheCacheIsLarge
org.opentest4j.AssertionFailedError: expected: <1000> but was: <0>

Two of the new tests pass in both phases by design — they are the "global-only behaviour is unchanged" regression guards (repoThatDeclaresNothingKeepsGlobalOnlyBehavior and repoWithNoDeclaredPatternsKeepsEveryFileTheGlobalListAllows); they must hold before and after.

With the production change restored, all of the above pass. Gates run locally:

  • ./mvnw -B spotless:apply — clean
  • ./mvnw -B clean compile spotbugs:check spotless:check — BUILD SUCCESS, BugInstance size is 0
  • ./mvnw -B clean test — BUILD SUCCESS, Tests run: 1902, 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

Additional Notes

The native build only runs on main, not on PRs. Residual risk there is low — snakeyaml and jackson-dataformat-yaml are already exercised at runtime in the native image by quarkus-smallrye-openapi, and this code path uses readTree with no reflective POJO binding — but it is worth a glance on the first main build after merge.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Dependency Review

The following issues were found:
  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ⚠️ 1 package(s) with unknown licenses.
See the Details below.

License Issues

pom.xml

PackageVersionLicenseIssue Type
com.fasterxml.jackson.dataformat:jackson-dataformat-yamlNullUnknown License
Allowed Licenses: MIT, MIT-0, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD, Unlicense, Zlib, EPL-1.0, EPL-2.0, MPL-1.1, MPL-2.0, LGPL-2.1-only, LGPL-2.1-or-later, LGPL-3.0-only, LGPL-3.0-or-later, GPL-2.0-only WITH Classpath-exception-2.0, GPL-2.0-or-later WITH Classpath-exception-2.0, CDDL-1.0, CDDL-1.1, UPL-1.0, CC0-1.0, CC-BY-4.0, BlueOak-1.0.0, Python-2.0, PSF-2.0
Excluded from license check: pkg:maven/io.netty/netty-bom, pkg:maven/io.opentelemetry/opentelemetry-bom, pkg:maven/io.opentelemetry/opentelemetry-bom-alpha, pkg:maven/io.quarkus.platform/quarkus-bom, pkg:maven/io.quarkus.platform/quarkus-maven-plugin, pkg:npm/robust-predicates

OpenSSF Scorecard

PackageVersionScoreDetails
maven/com.fasterxml.jackson.dataformat:jackson-dataformat-yaml UnknownUnknown

Scanned Files

  • pom.xml

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@thrillhousebot

thrillhousebot Bot commented Aug 8, 2026

Copy link
Copy Markdown

🤖 ThrillhouseBot PR Summary

What this PR does

Adds support for per-repository ignore globs via an optional .github/thrillhousebot.yml/.yaml file, unioned with the global deployment ignore list, with caching and fail-soft behaviour.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
    A["ReviewContextLoader.load()"] --> B["resolveIgnoreGlobs(req)"]
    B --> C["SoftLoaders.repoSettings()"]
    C --> D["RepoSettingsResolver.resolve()"]
    D --> E{"Feature
enabled?"}
    E -->|no| F["return EMPTY"]
    E -->|yes| G{"Cache
hit?"}
    G -->|yes| H["return cached"]
    G -->|no| I["try .yml → .yaml"]
    I --> J{"Fetch & parse"}
    J -->|success| K["cache 5min, return settings"]
    J -->|fail| L["cache EMPTY 1min, return EMPTY"]
    D --> M["diffFormatter.ignoreGlobs(patterns)"]
    M --> N["globalGlobs.union(perRepo)"]
    N --> O["reviewableFiles(files, ignoreGlobs)"]
    O --> P["buildBaseComparison with ignoreGlobs"]
    P --> Q["ReviewContext"]
Loading

Changes Overview

  • Files changed: 15
  • Lines added: +1086
  • Lines removed: -21

Changed Files

File Change Summary
.env.example Modified -
CHANGELOG.md Modified -
README.md Modified -
src/main/java/dev/thiagogonzaga/thrillhousebot/config/ThrillhouseConfig.java Modified -
src/main/java/dev/thiagogonzaga/thrillhousebot/github/RepoSettings.java Added Record holding per-repo settings (ignoredFiles and source path).
src/main/java/dev/thiagogonzaga/thrillhousebot/github/RepoSettingsParser.java Added Parses untrusted YAML config with strict limits, returning EMPTY on malformation.
src/main/java/dev/thiagogonzaga/thrillhousebot/github/RepoSettingsResolver.java Added Fetches and caches per-repo config with 5-min TTL, 1-min negative cache, and sweep.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewContextLoader.java Modified Resolves per-repo ignoreGlobs and passes them to diff formatter for review and base comparison.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ReviewDiffFormatter.java Modified Wraps glob matching in IgnoreGlobs record, adds union and new overloads for per-repo patterns.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/SoftLoaders.java Modified Fail-soft wrapper for RepoSettingsResolver to guarantee no per-repo config errors propagate.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/RepoSettingsResolverTest.java Added 17 unit tests covering parsing, fallback, caching, caps, and fail-soft behavior.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewContextLoaderTest.java Modified 3 end-to-end tests for per-repo patterns narrowing reviewable files and fail-soft fallback.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewDiffFormatterTest.java Modified 5 tests verifying per-repo pattern union, effective set, and base comparison scoping.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewOrchestratorTest.java Modified -

…and 1 more file(s).

Risk Assessment

Risk Count
🔴 Critical 0
🟠 High 0
🟡 Medium 1
🔵 Low 0

Things to double-check

1 lower-confidence finding
  • MEDIUM: Undecodable content causes fallback to alternate config name, contradicting design (src/main/java/dev/thiagogonzaga/thrillhousebot/github/RepoSettingsResolver.java:166) (low confidence — verify before acting)

⚠️ CI Checks Status

Some checks are still pending or have failed:

Check Type Status Detail
trivy check-run ⏳ Pending -
test check-run ⏳ Pending -
actionlint check-run ⏳ Pending -
format check-run ⏳ Pending -
changes check-run ⏳ Pending -
frontend check-run ⏳ Pending -
dependency-review check-run ⏳ Pending -
build check-run ⏳ Pending -

Automated review by ThrillhouseBot. Reply with /review to re-run.

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

  • MEDIUM: Undecodable content causes fallback to alternate config name, contradicting design (src/main/java/dev/thiagogonzaga/thrillhousebot/github/RepoSettingsResolver.java:166)
    The documented intention is that the first existing config file (.yml or .yaml) is selected; if it is present but unreadable, it should parse to RepoSettings.EMPTY and not fall back to the alternate name. However, fetchAndParse catches RuntimeException from Base64.getMimeDecoder().decode() and returns null, which causes the resolve loop to try the next file path. If both .yml (corrupted) and .yaml (valid) exist, the settings from the .yaml file are used instead of EMPTY. This deviates from the stated design that a repo's config file, once found, is the authoritative source.

@thrillhousebot thrillhousebot Bot added enhancement New feature or request testing Test coverage and test quality labels Aug 8, 2026
…efault

thrillhousebot.review.ignored-files is app-wide, so one deployment reviewing
many repositories has to pick a single list for all of them — a repo with
generated dirs, vendored code, or large fixtures has no way to say so.

A repository can now declare ignore globs of its own under review.ignored-files
in an optional .github/thrillhousebot.yml. They are additive: the effective set
is global union per-repo, so a repository can take more files out of review
scope but can never put back a file the deployment excludes.

Structured settings live in a dedicated file rather than frontmatter in the
instructions file, because the instructions fallback chain deliberately reaches
into files owned by other tools (copilot-instructions.md, CLAUDE.md, AGENTS.md)
and its content is handed to the model as untrusted prose — config there would
either leak into the prompt or need stripping out of it.

The existing glob matching in ReviewDiffFormatter is wrapped in an IgnoreGlobs
value type that both lists compile through, so a repository cannot get different
matching semantics than the global default. The effective set is resolved once
per review in ReviewContextLoader and threaded into the single reviewableFiles
call, preserving the compute-once property.

Everything fails soft: feature off, file absent, transport error, undecodable
content, malformed YAML, unexpected shape, or an uncompilable glob all degrade
to the global list rather than failing the review. The parser reads a generic
tree (no reflection) with snakeyaml loader limits and caps on pattern count and
length, since the file is untrusted input from an arbitrary repository.
jackson-dataformat-yaml was already on the compile classpath via
quarkus-smallrye-openapi and version-managed by the jackson-bom import; it is
now declared explicitly because it is used directly.

thrillhousebot.review.repo-config-enabled (default true) is the operator kill
switch for installs that must not let a repository narrow its own review scope.

Refs #51
@devops-thiago
devops-thiago force-pushed the feat/51-per-repo-ignores branch from 677d27f to 2eeb1a3 Compare August 8, 2026 00:48

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

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.

@thrillhousebot thrillhousebot Bot added the java Pull requests that update java code label Aug 8, 2026
…er its branches

The docs build failed: starlight-links-validator rejected the
#repository-configuration cross-reference in the config table. That table is
mirrored into website/src/content/docs/configuration.md by the remark-include
plugin, so an anchor used inside the docs:configuration block has to resolve on
that rendered page — every other anchor in the block already targets a heading
that is included alongside it.

Wrap the Repository configuration section in its own docs markers and include it
on the configuration page, between the config table that links to it and the PR
labels section, matching the README's own order. The section's opening line said
"the instructions file above", which only held in the README, so it now names
the file outright and reads correctly on both pages.

Also close the patch-coverage gaps in the same feature. Three of them were dead
defensive branches rather than untested behaviour, and are removed: readTree
never returns null for non-blank input (an ObjectNode pattern match now rejects
a null, missing, scalar or sequence root in one test), path() never returns null,
and the entries feeding sanitize come from asText()/String.split so they are
never null. readPatterns switches on the node type, which states the four shapes
it accepts directly and drops the compound early-return. RepoSettings.isPresent
was speculative API with no production caller and is gone.

The rest were genuinely untested fail-soft paths, now covered: a blank or
comment-only config file, an explicitly empty ignored-files key, a non-scalar
entry inside the list, a response carrying no content (which would have NPEd in
the base64 decode), the @Inject constructor CDI actually uses, a per-repo list
whose patterns are all invalid, and a null ignore set. Every file the feature
touches is now fully covered.

Refs #51

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

No new issues in this revision, but 2 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 merged commit 28b47fd into release/v0.6.0 Aug 8, 2026
16 checks passed
@devops-thiago
devops-thiago deleted the feat/51-per-repo-ignores branch August 8, 2026 11:10
devops-thiago added a commit that referenced this pull request Aug 8, 2026
Absorbs #449 (per-repo ignore patterns), #451 (whole-change-set PR summary),
#453 (decline re-check) and four dependency bumps.

Two textual conflicts, both from independent additions at the same insertion
point rather than any disagreement:

- ReviewContextLoader: #449's resolveIgnoreGlobs and this branch's
  resolveConfigKeyContext are separate private helpers that git could not
  place. Kept both.
- FindingPipelineTest: #451 parameterized the reviewContext helper with an
  explicit reviewable-file list while this branch added the configKeyContext
  record component. Kept both — the helper's parameter, with "" in the new
  component's position.

One silent breakage git merged cleanly: #453's new declinedRaceContext helper
constructs a ReviewContext without configKeyContext. Filled in.

The interaction between the two features is the one worth noting. #449 made
load() compute reviewableFiles from the global globs unioned with the repo's
own, and config-key resolution already read that post-filter list, so a key
documented only in an ignored file is now correctly never resolved — and it
inherits per-repo ignore rules for free. Pinned with a test that fails if the
raw file list is ever passed instead.
devops-thiago added a commit that referenced this pull request Aug 8, 2026
/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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request java Pull requests that update java code testing Test coverage and test quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant