Skip to content

Java / JVM

Brimyr detects a Maven repo from pom.xml, runs

mvn -B org.jacoco:jacoco-maven-plugin:prepare-agent test org.jacoco:jacoco-maven-plugin:report

and reads every **/target/site/jacoco/jacoco.xml it produced.

The plugin goals are invoked by coordinate, so this works on a pom.xml that has no JaCoCo plugin configured at all, nothing has to be added to the build to start gating. If your pom.xml already binds prepare-agent and report to the build lifecycle, a plain mvn -B verify is enough and you can say so:

        with:
          test_command: 'mvn -B verify'

JaCoCo XML is not Cobertura

This is the one thing to get right. JaCoCo emits its own XML format, and it shares the .xml extension with Cobertura:

<report name="isam3d-case">
  <package name="nl/example/isam/case">
    <sourcefile name="CaseService.java">
      <line nr="12" mi="0" ci="4"/>   <!-- ci = covered instructions -->
      <line nr="13" mi="3" ci="0"/>   <!-- mi = missed instructions  -->

Brimyr tells the two apart by the root element (<report> vs <coverage>), not by the file name, so coverage_file: target/site/jacoco/jacoco.xml just works.

Why the extension is not enough

Parsing a JaCoCo file as Cobertura does not fail. It finds no <class filename=…>, returns an empty report, and every changed Java file is then a file the report never mentions. Those files leave the denominator, and the gate gladly reports 100% over completely untested code. If a tool ever hands you a coverage number you cannot reconcile, this is the first thing to check.

You can always be explicit:

        with:
          coverage_file: 'build/reports/jacoco/test/jacocoTestReport.xml:jacoco'

Partially covered lines count as covered

A line with ci>0 and mi>0 is usually a short-circuited boolean. JaCoCo's own LINE counter calls it covered and so does diff-cover, so Brimyr does too, otherwise every a && b in a pull request would count against you.

Multi-module reactors

A multi-module build writes one report per module, each under that module's own target/. Brimyr ingests all of them and merges them covered-wins, so a class exercised by a sibling module's tests comes out covered.

This matters for the same reason it matters on a multi-project .NET solution: a missing report is indistinguishable from nothing coverable changed, so dropping one silently removes that module's changed lines from the denominator and inflates the number. If a reactor build reports a suspiciously round 100%, count the reports:

find . -path '*/target/site/jacoco/jacoco.xml' | wc -l   # expect one per module with tests

Aggregating into a single report first (the report-aggregate goal) also works, point coverage_file at it and Brimyr will not run any tests.

Source paths

JaCoCo names files as <package>/<sourcefile>, nl/example/isam/case/CaseService.java, which is source-root-relative and so is missing the backend/src/main/java/ prefix that git diff reports. Brimyr reconciles that by suffix matching; there is nothing to configure.

Gradle

build.gradle and build.gradle.kts are recognised as Java markers but are not auto-detected, because the built-in command is mvn and running it in a Gradle repo would fail the run and turn the build red. The JaCoCo parser is shared, only the invocation differs, so name the command:

        with:
          ecosystem: 'java'
          test_command: './gradlew test jacocoTestReport'
          coverage_file: 'build/reports/jacoco/test/jacocoTestReport.xml'

Gradle's jacocoTestReport task writes HTML by default; make sure XML is on (reports { xml.required = true }).

Excluding generated code

The JVM equivalent of the .NET problem: generated sources, MapStruct/Lombok output, JAXB and OpenAPI stubs. exclude drops matching changed files from the denominator entirely. They are not counted as covered, they simply do not count:

        with:
          exclude: "**/generated-sources/**,**/target/**,*_MapperImpl.java,**/generated/**"

Globs match the repo-relative, forward-slash path and * crosses /, so a pattern catches a folder at any depth without you having to know how deep it sits.

JaCoCo's own <excludes> configuration works too, and is preferable when you want the class gone from all reporting rather than just from the gate: anything absent from the report is absent from the denominator already.

Full example

name: Coverage
on: [pull_request]

permissions:
  contents: read
  pull-requests: write   # for the PR comment

jobs:
  coverage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with: { fetch-depth: 0 }        # patch coverage needs the merge-base
      - uses: actions/setup-java@v4
        with: { distribution: 'temurin', java-version: '21', cache: 'maven' }
      - uses: magmamoose/brimyr@v1
        with:
          checkout: 'false'
          threshold: '80'
          pr_comment: 'true'
          exclude: "**/generated-sources/**,**/generated/**"

Start with mode: baseline, which computes and reports the number without gating. Once the numbers look right on real pull requests, switch to the default and pick a threshold from what you actually saw.

SonarQube

sonar.coverage.jacoco.xmlReportPaths is set from the reports Brimyr found, so the same run that gates the pull request can also feed the SonarQube trend, non-blocking, as always.

Java needs sonar.java.binaries

sonar-scanner -Dsonar.sources=. over a Java repo fails outright with "please provide compiled classes with sonar.java.binaries". Brimyr cannot infer it, so rather than run a scan that cannot succeed it skips with a warning until you supply it:

    with:
      sonar_url: https://sonar.example.com
      sonar_args: '-Dsonar.java.binaries=**/target/classes'

Sonar also documents that the CLI scanner should not be used for Maven or Gradle projects at all, mvn sonar:sonar is the supported path and will give a better analysis. Brimyr's patch-coverage gate is unaffected either way: it reads the JaCoCo reports directly and never talks to SonarQube.