From e1d6be8778c3f2216c2810a75ba5c7fbf1f44dc5 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 21 Mar 2026 03:01:59 +0100 Subject: [PATCH 01/20] ci: add per-module coverage reporting --- .../scripts/quality/build_coverage_summary.py | 91 +++++++++++++++++++ .../scripts/quality/write_coverage_summary.sh | 8 +- .github/workflows/pr-report.yml | 13 +++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/quality/build_coverage_summary.py diff --git a/.github/scripts/quality/build_coverage_summary.py b/.github/scripts/quality/build_coverage_summary.py new file mode 100644 index 00000000..21597366 --- /dev/null +++ b/.github/scripts/quality/build_coverage_summary.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def read_line_counter(report: Path) -> tuple[int, int] | None: + try: + root = ET.parse(report).getroot() + except ET.ParseError: + return None + + for counter in root.findall("./counter"): + if counter.attrib.get("type") == "LINE": + covered = int(counter.attrib.get("covered", "0")) + missed = int(counter.attrib.get("missed", "0")) + return covered, missed + return None + + +def format_ratio(covered: int, missed: int) -> str: + total = covered + missed + if total == 0: + return "0.00%" + return f"{covered / total * 100:.2f}%" + + +def main() -> int: + if len(sys.argv) != 3: + print("usage: build_coverage_summary.py ", file=sys.stderr) + return 2 + + reports_root = Path(sys.argv[1]) + output_file = Path(sys.argv[2]) + + module_rows: list[tuple[str, int, int]] = [] + fallback_rows: list[tuple[str, int, int]] = [] + + for report in sorted(reports_root.glob("**/build/reports/kover/report.xml")): + counts = read_line_counter(report) + if counts is None: + continue + + covered, missed = counts + rel = report.relative_to(reports_root) + parts = rel.parts + + if len(parts) >= 5 and parts[1:5] == ("build", "reports", "kover", "report.xml"): + module_rows.append((parts[0], covered, missed)) + else: + fallback_rows.append((str(rel.parent), covered, missed)) + + rows = module_rows or fallback_rows + total_covered = sum(covered for _, covered, _ in rows) + total_missed = sum(missed for _, _, missed in rows) + total_lines = total_covered + total_missed + + lines = [ + "## Coverage", + "", + ( + f"- Overall line coverage: {format_ratio(total_covered, total_missed)} " + f"({total_covered} covered / {total_lines} total)" + ), + ] + + if rows: + lines.extend( + [ + "", + "| Module | Coverage | Covered | Total |", + "| --- | ---: | ---: | ---: |", + ] + ) + + for module, covered, missed in sorted(rows): + total = covered + missed + lines.append( + f"| `{module}` | {format_ratio(covered, missed)} | {covered} | {total} |" + ) + else: + lines.extend(["", "- No Kover XML reports found"]) + + output_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/quality/write_coverage_summary.sh b/.github/scripts/quality/write_coverage_summary.sh index ea3bbd3c..cd2648e9 100644 --- a/.github/scripts/quality/write_coverage_summary.sh +++ b/.github/scripts/quality/write_coverage_summary.sh @@ -1,9 +1,13 @@ #!/usr/bin/env bash set -euo pipefail +summary_file="$(mktemp)" +trap 'rm -f "$summary_file"' EXIT + +python3 .github/scripts/quality/build_coverage_summary.py . "$summary_file" + { - echo "## Coverage" + cat "$summary_file" echo - echo "Kover XML report generated at \`build/reports/kover/report.xml\`" echo "Coverage artifacts are attached to this workflow run." } >> "${GITHUB_STEP_SUMMARY:?}" diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index 9308c6b7..fd31af0c 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -96,3 +96,16 @@ jobs: number: ${{ github.event.workflow_run.pull_requests[0].number }} header: ci-quality-overview path: pr-summary.md + + - name: Build coverage by module summary + if: hashFiles('pr-artifacts/**/build/reports/kover/report.xml') != '' + shell: bash + run: python3 .github/scripts/quality/build_coverage_summary.py pr-artifacts coverage-summary.md + + - name: Publish coverage by module summary + if: hashFiles('pr-artifacts/**/build/reports/kover/report.xml') != '' + uses: marocchino/sticky-pull-request-comment@v3.0.2 + with: + number: ${{ github.event.workflow_run.pull_requests[0].number }} + header: coverage-by-module + path: coverage-summary.md From be99ef08965469d6a96e1e6eeff8d6dcdec864a0 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 21 Mar 2026 03:28:41 +0100 Subject: [PATCH 02/20] ci: switch coverage reporting to quality monitor --- .github/quality-monitor/coverage-config.json | 24 ++++++++ .github/scripts/pr/build_pr_summary.py | 29 --------- .../scripts/quality/build_coverage_summary.py | 61 ++++++++++++------- .github/workflows/pr-report.yml | 27 +++++--- 4 files changed, 82 insertions(+), 59 deletions(-) create mode 100644 .github/quality-monitor/coverage-config.json diff --git a/.github/quality-monitor/coverage-config.json b/.github/quality-monitor/coverage-config.json new file mode 100644 index 00000000..60a75925 --- /dev/null +++ b/.github/quality-monitor/coverage-config.json @@ -0,0 +1,24 @@ +{ + "coverage": [ + { + "name": "Line Coverage", + "tools": [ + { + "id": "jacoco", + "metric": "line", + "pattern": "pr-artifacts/**/build/reports/kover/report.xml" + } + ] + }, + { + "name": "Instruction Coverage", + "tools": [ + { + "id": "jacoco", + "metric": "instruction", + "pattern": "pr-artifacts/**/build/reports/kover/report.xml" + } + ] + } + ] +} diff --git a/.github/scripts/pr/build_pr_summary.py b/.github/scripts/pr/build_pr_summary.py index f62a22c5..b82ccb29 100644 --- a/.github/scripts/pr/build_pr_summary.py +++ b/.github/scripts/pr/build_pr_summary.py @@ -63,23 +63,6 @@ def count_sarif_results(files: list[Path]) -> int: return count -def aggregate_line_coverage(files: list[Path]) -> tuple[int, int]: - covered = 0 - missed = 0 - for file in files: - try: - root = ET.parse(file).getroot() - except ET.ParseError: - continue - - for counter in root.findall("./counter"): - if counter.attrib.get("type") == "LINE": - covered += int(counter.attrib.get("covered", "0")) - missed += int(counter.attrib.get("missed", "0")) - break - return covered, missed - - def format_test_line(label: str, metrics: dict[str, int]) -> str: total_failures = metrics["failures"] + metrics["errors"] status = "PASS" if total_failures == 0 else "FAIL" @@ -104,7 +87,6 @@ def main() -> int: instrumentation_xml = sorted(artifacts_dir.glob("**/instrumentation-results.xml")) lint_xml = sorted(artifacts_dir.glob("**/build/reports/lint-results-*.xml")) ktlint_xml = sorted(artifacts_dir.glob("**/build/reports/ktlint/**/*.xml")) - kover_xml = sorted(artifacts_dir.glob("**/build/reports/kover/**/*.xml")) detekt_sarif = sorted(artifacts_dir.glob("**/build/reports/detekt/*.sarif")) gitleaks_sarif = sorted(artifacts_dir.glob("**/build/reports/gitleaks/*.sarif")) @@ -114,10 +96,6 @@ def main() -> int: ktlint_issues = count_ktlint_issues(ktlint_xml) detekt_findings = count_sarif_results(detekt_sarif) gitleaks_findings = count_sarif_results(gitleaks_sarif) - covered, missed = aggregate_line_coverage(kover_xml) - total_lines = covered + missed - coverage = (covered / total_lines * 100.0) if total_lines else None - pr_number = sys.argv[3] head_sha = sys.argv[4] run_url = sys.argv[5] @@ -145,13 +123,6 @@ def main() -> int: f"- detekt: {detekt_findings} finding(s) uploaded via SARIF" if detekt_sarif else "- detekt: no SARIF reports found", f"- gitleaks: {gitleaks_findings} finding(s) uploaded via SARIF" if gitleaks_sarif else "- gitleaks: no SARIF reports found", "- CodeQL: see the Code Scanning section in this PR", - "", - "### Coverage", - ( - f"- Line coverage: {coverage:.2f}% ({covered} covered / {total_lines} total)" - if coverage is not None - else "- Line coverage: no Kover XML reports found" - ), ] output_file.write_text("\n".join(line for line in lines if line is not None) + "\n") diff --git a/.github/scripts/quality/build_coverage_summary.py b/.github/scripts/quality/build_coverage_summary.py index 21597366..c0c43f45 100644 --- a/.github/scripts/quality/build_coverage_summary.py +++ b/.github/scripts/quality/build_coverage_summary.py @@ -6,18 +6,26 @@ from pathlib import Path -def read_line_counter(report: Path) -> tuple[int, int] | None: +MetricCounts = dict[str, tuple[int, int]] + + +def read_counters(report: Path) -> MetricCounts | None: try: root = ET.parse(report).getroot() except ET.ParseError: return None + counters: MetricCounts = {} for counter in root.findall("./counter"): - if counter.attrib.get("type") == "LINE": - covered = int(counter.attrib.get("covered", "0")) - missed = int(counter.attrib.get("missed", "0")) - return covered, missed - return None + metric = counter.attrib.get("type") + if metric is None: + continue + counters[metric] = ( + int(counter.attrib.get("covered", "0")), + int(counter.attrib.get("missed", "0")), + ) + + return counters or None def format_ratio(covered: int, missed: int) -> str: @@ -35,34 +43,41 @@ def main() -> int: reports_root = Path(sys.argv[1]) output_file = Path(sys.argv[2]) - module_rows: list[tuple[str, int, int]] = [] - fallback_rows: list[tuple[str, int, int]] = [] + module_rows: list[tuple[str, MetricCounts]] = [] + fallback_rows: list[tuple[str, MetricCounts]] = [] for report in sorted(reports_root.glob("**/build/reports/kover/report.xml")): - counts = read_line_counter(report) + counts = read_counters(report) if counts is None: continue - covered, missed = counts rel = report.relative_to(reports_root) parts = rel.parts if len(parts) >= 5 and parts[1:5] == ("build", "reports", "kover", "report.xml"): - module_rows.append((parts[0], covered, missed)) + module_rows.append((parts[0], counts)) else: - fallback_rows.append((str(rel.parent), covered, missed)) + fallback_rows.append((str(rel.parent), counts)) rows = module_rows or fallback_rows - total_covered = sum(covered for _, covered, _ in rows) - total_missed = sum(missed for _, _, missed in rows) - total_lines = total_covered + total_missed + total_line_covered = sum(counts.get("LINE", (0, 0))[0] for _, counts in rows) + total_line_missed = sum(counts.get("LINE", (0, 0))[1] for _, counts in rows) + total_instruction_covered = sum(counts.get("INSTRUCTION", (0, 0))[0] for _, counts in rows) + total_instruction_missed = sum(counts.get("INSTRUCTION", (0, 0))[1] for _, counts in rows) + total_lines = total_line_covered + total_line_missed + total_instructions = total_instruction_covered + total_instruction_missed lines = [ "## Coverage", "", ( - f"- Overall line coverage: {format_ratio(total_covered, total_missed)} " - f"({total_covered} covered / {total_lines} total)" + f"- Overall line coverage: {format_ratio(total_line_covered, total_line_missed)} " + f"({total_line_covered} covered / {total_lines} total)" + ), + ( + f"- Overall instruction coverage: " + f"{format_ratio(total_instruction_covered, total_instruction_missed)} " + f"({total_instruction_covered} covered / {total_instructions} total)" ), ] @@ -70,15 +85,17 @@ def main() -> int: lines.extend( [ "", - "| Module | Coverage | Covered | Total |", - "| --- | ---: | ---: | ---: |", + "| Module | Line Coverage | Instruction Coverage |", + "| --- | ---: | ---: |", ] ) - for module, covered, missed in sorted(rows): - total = covered + missed + for module, counts in sorted(rows): + line_covered, line_missed = counts.get("LINE", (0, 0)) + instruction_covered, instruction_missed = counts.get("INSTRUCTION", (0, 0)) lines.append( - f"| `{module}` | {format_ratio(covered, missed)} | {covered} | {total} |" + f"| `{module}` | {format_ratio(line_covered, line_missed)} | " + f"{format_ratio(instruction_covered, instruction_missed)} |" ) else: lines.extend(["", "- No Kover XML reports found"]) diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index fd31af0c..67523679 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -65,19 +65,30 @@ jobs: update_check: true detailed_summary: true + - name: Load quality monitor coverage config + if: hashFiles('pr-artifacts/**/build/reports/kover/*.xml') != '' + id: quality-monitor-config + shell: bash + run: | + { + echo 'json<> "$GITHUB_OUTPUT" + - name: Publish coverage report if: hashFiles('pr-artifacts/**/build/reports/kover/*.xml') != '' continue-on-error: true - uses: madrapps/jacoco-report@v1.7.2 + uses: uhafner/quality-monitor@v4.7.1 with: pr-number: ${{ github.event.workflow_run.pull_requests[0].number }} - paths: ${{ github.workspace }}/pr-artifacts/**/build/reports/kover/*.xml - token: ${{ github.token }} - min-coverage-overall: 60 - min-coverage-changed-files: 60 - title: Coverage - update-comment: true - comment-type: both + sha: ${{ github.event.workflow_run.head_sha }} + github-token: ${{ github.token }} + checks-name: Coverage + title-metric: line + show-headers: true + skip-annotations: true + config: ${{ steps.quality-monitor-config.outputs.json }} - name: Build PR summary shell: bash From 38e90e403e0e750eaf062500ed7bd1bc9345739a Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 21 Mar 2026 03:36:44 +0100 Subject: [PATCH 03/20] ci: publish only canonical coverage comments --- .github/scripts/quality/build_coverage_summary.py | 10 +++++++--- .github/workflows/pr-report.yml | 1 - 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/scripts/quality/build_coverage_summary.py b/.github/scripts/quality/build_coverage_summary.py index c0c43f45..7c674c82 100644 --- a/.github/scripts/quality/build_coverage_summary.py +++ b/.github/scripts/quality/build_coverage_summary.py @@ -85,17 +85,21 @@ def main() -> int: lines.extend( [ "", - "| Module | Line Coverage | Instruction Coverage |", - "| --- | ---: | ---: |", + "| Module | Line Coverage | Line Covered | Line Total | Instruction Coverage | Instruction Covered | Instruction Total |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: |", ] ) for module, counts in sorted(rows): line_covered, line_missed = counts.get("LINE", (0, 0)) instruction_covered, instruction_missed = counts.get("INSTRUCTION", (0, 0)) + line_total = line_covered + line_missed + instruction_total = instruction_covered + instruction_missed lines.append( f"| `{module}` | {format_ratio(line_covered, line_missed)} | " - f"{format_ratio(instruction_covered, instruction_missed)} |" + f"{line_covered} | {line_total} | " + f"{format_ratio(instruction_covered, instruction_missed)} | " + f"{instruction_covered} | {instruction_total} |" ) else: lines.extend(["", "- No Kover XML reports found"]) diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index 67523679..34eaf616 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -81,7 +81,6 @@ jobs: continue-on-error: true uses: uhafner/quality-monitor@v4.7.1 with: - pr-number: ${{ github.event.workflow_run.pull_requests[0].number }} sha: ${{ github.event.workflow_run.head_sha }} github-token: ${{ github.token }} checks-name: Coverage From 537401f2b5a3a077dcdfd0b04257aefc7bb6b30e Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 21 Mar 2026 03:43:01 +0100 Subject: [PATCH 04/20] ci: publish coverage from aggregated kover report --- .github/quality-monitor/coverage-config.json | 4 ++-- .github/workflows/pr-report.yml | 14 +------------- build.gradle.kts | 7 +++++++ 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/.github/quality-monitor/coverage-config.json b/.github/quality-monitor/coverage-config.json index 60a75925..d76db497 100644 --- a/.github/quality-monitor/coverage-config.json +++ b/.github/quality-monitor/coverage-config.json @@ -6,7 +6,7 @@ { "id": "jacoco", "metric": "line", - "pattern": "pr-artifacts/**/build/reports/kover/report.xml" + "pattern": "pr-artifacts/quality-coverage-reports/build/reports/kover/report.xml" } ] }, @@ -16,7 +16,7 @@ { "id": "jacoco", "metric": "instruction", - "pattern": "pr-artifacts/**/build/reports/kover/report.xml" + "pattern": "pr-artifacts/quality-coverage-reports/build/reports/kover/report.xml" } ] } diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index 34eaf616..447ea9cb 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -81,6 +81,7 @@ jobs: continue-on-error: true uses: uhafner/quality-monitor@v4.7.1 with: + pr-number: ${{ github.event.workflow_run.pull_requests[0].number }} sha: ${{ github.event.workflow_run.head_sha }} github-token: ${{ github.token }} checks-name: Coverage @@ -106,16 +107,3 @@ jobs: number: ${{ github.event.workflow_run.pull_requests[0].number }} header: ci-quality-overview path: pr-summary.md - - - name: Build coverage by module summary - if: hashFiles('pr-artifacts/**/build/reports/kover/report.xml') != '' - shell: bash - run: python3 .github/scripts/quality/build_coverage_summary.py pr-artifacts coverage-summary.md - - - name: Publish coverage by module summary - if: hashFiles('pr-artifacts/**/build/reports/kover/report.xml') != '' - uses: marocchino/sticky-pull-request-comment@v3.0.2 - with: - number: ${{ github.event.workflow_run.pull_requests[0].number }} - header: coverage-by-module - path: coverage-summary.md diff --git a/build.gradle.kts b/build.gradle.kts index 8c4364cd..52509b2c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,13 @@ plugins { alias(libs.plugins.kover) } +dependencies { + kover(project(":ai")) + kover(project(":app")) + kover(project(":data")) + kover(project(":domain")) +} + configure { android.set(true) ignoreFailures.set(false) From e35c51124c27b942e91606f4b9da419d24dedd90 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 21 Mar 2026 03:55:51 +0100 Subject: [PATCH 05/20] ci: fix aggregated coverage reporting paths --- .github/quality-monitor/coverage-config.json | 4 ++-- build.gradle.kts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/quality-monitor/coverage-config.json b/.github/quality-monitor/coverage-config.json index d76db497..1c5d9dff 100644 --- a/.github/quality-monitor/coverage-config.json +++ b/.github/quality-monitor/coverage-config.json @@ -6,7 +6,7 @@ { "id": "jacoco", "metric": "line", - "pattern": "pr-artifacts/quality-coverage-reports/build/reports/kover/report.xml" + "pattern": "pr-artifacts/build/reports/kover/report.xml" } ] }, @@ -16,7 +16,7 @@ { "id": "jacoco", "metric": "instruction", - "pattern": "pr-artifacts/quality-coverage-reports/build/reports/kover/report.xml" + "pattern": "pr-artifacts/build/reports/kover/report.xml" } ] } diff --git a/build.gradle.kts b/build.gradle.kts index 52509b2c..5e1a3388 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -71,6 +71,10 @@ kover { } } +tasks.matching { it.name == "koverVerify" }.configureEach { + enabled = false +} + subprojects { apply(plugin = "org.jlleitschuh.gradle.ktlint") apply(plugin = "io.gitlab.arturbosch.detekt") From 2999190b8d0b8ee9fa2a8f3a6728c3da747a5d89 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 21 Mar 2026 04:00:24 +0100 Subject: [PATCH 06/20] ci: publish only aggregated coverage artifact --- .github/quality-monitor/coverage-config.json | 4 ++-- .github/workflows/quality.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/quality-monitor/coverage-config.json b/.github/quality-monitor/coverage-config.json index 1c5d9dff..62f6f519 100644 --- a/.github/quality-monitor/coverage-config.json +++ b/.github/quality-monitor/coverage-config.json @@ -6,7 +6,7 @@ { "id": "jacoco", "metric": "line", - "pattern": "pr-artifacts/build/reports/kover/report.xml" + "pattern": "**/build/reports/kover/report.xml" } ] }, @@ -16,7 +16,7 @@ { "id": "jacoco", "metric": "instruction", - "pattern": "pr-artifacts/build/reports/kover/report.xml" + "pattern": "**/build/reports/kover/report.xml" } ] } diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index a85fe197..f08759ee 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -255,7 +255,7 @@ jobs: name: quality-coverage-reports if-no-files-found: ignore path: | - **/build/reports/kover/** + build/reports/kover/** - name: Publish coverage summary if: always() From 60c4fe724ce476f4d182d1d8b5d46464f32100d5 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 21 Mar 2026 04:04:45 +0100 Subject: [PATCH 07/20] ci: match downloaded aggregated coverage report --- .github/quality-monitor/coverage-config.json | 4 ++-- .github/workflows/pr-report.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/quality-monitor/coverage-config.json b/.github/quality-monitor/coverage-config.json index 62f6f519..a28028bf 100644 --- a/.github/quality-monitor/coverage-config.json +++ b/.github/quality-monitor/coverage-config.json @@ -6,7 +6,7 @@ { "id": "jacoco", "metric": "line", - "pattern": "**/build/reports/kover/report.xml" + "pattern": "pr-artifacts/report.xml" } ] }, @@ -16,7 +16,7 @@ { "id": "jacoco", "metric": "instruction", - "pattern": "**/build/reports/kover/report.xml" + "pattern": "pr-artifacts/report.xml" } ] } diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index 447ea9cb..25e36859 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -66,7 +66,7 @@ jobs: detailed_summary: true - name: Load quality monitor coverage config - if: hashFiles('pr-artifacts/**/build/reports/kover/*.xml') != '' + if: hashFiles('pr-artifacts/report.xml') != '' id: quality-monitor-config shell: bash run: | @@ -77,7 +77,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Publish coverage report - if: hashFiles('pr-artifacts/**/build/reports/kover/*.xml') != '' + if: hashFiles('pr-artifacts/report.xml') != '' continue-on-error: true uses: uhafner/quality-monitor@v4.7.1 with: From d5cf46898ad1845c3407c1fd4c491d7d3935136e Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sat, 21 Mar 2026 04:11:50 +0100 Subject: [PATCH 08/20] ci: use glob pattern for aggregated coverage report --- .github/quality-monitor/coverage-config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/quality-monitor/coverage-config.json b/.github/quality-monitor/coverage-config.json index a28028bf..01f6e607 100644 --- a/.github/quality-monitor/coverage-config.json +++ b/.github/quality-monitor/coverage-config.json @@ -6,7 +6,7 @@ { "id": "jacoco", "metric": "line", - "pattern": "pr-artifacts/report.xml" + "pattern": "**/report.xml" } ] }, @@ -16,7 +16,7 @@ { "id": "jacoco", "metric": "instruction", - "pattern": "pr-artifacts/report.xml" + "pattern": "**/report.xml" } ] } From 18ea9a387983a8f67c301ccf774cb6f7f941b08d Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sun, 22 Mar 2026 15:22:54 +0100 Subject: [PATCH 09/20] ci: add detailed module coverage reporting --- .github/quality-monitor/coverage-config.json | 4 +- .../scripts/pr/list_downloaded_artifacts.sh | 2 +- .../scripts/quality/build_coverage_summary.py | 123 ++++++++++++------ .../quality/prepare_coverage_artifacts.sh | 22 ++++ .github/workflows/pr-report.yml | 20 ++- .github/workflows/quality.yml | 8 +- 6 files changed, 132 insertions(+), 47 deletions(-) create mode 100644 .github/scripts/quality/prepare_coverage_artifacts.sh diff --git a/.github/quality-monitor/coverage-config.json b/.github/quality-monitor/coverage-config.json index 01f6e607..e3896f37 100644 --- a/.github/quality-monitor/coverage-config.json +++ b/.github/quality-monitor/coverage-config.json @@ -6,7 +6,7 @@ { "id": "jacoco", "metric": "line", - "pattern": "**/report.xml" + "pattern": "**/aggregate/report.xml" } ] }, @@ -16,7 +16,7 @@ { "id": "jacoco", "metric": "instruction", - "pattern": "**/report.xml" + "pattern": "**/aggregate/report.xml" } ] } diff --git a/.github/scripts/pr/list_downloaded_artifacts.sh b/.github/scripts/pr/list_downloaded_artifacts.sh index 4951d124..8e00cf26 100644 --- a/.github/scripts/pr/list_downloaded_artifacts.sh +++ b/.github/scripts/pr/list_downloaded_artifacts.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -euo pipefail -find "${1:-pr-artifacts}" -maxdepth 4 -type f | sort +find "${1:-pr-artifacts}" -maxdepth 8 -type f | sort diff --git a/.github/scripts/quality/build_coverage_summary.py b/.github/scripts/quality/build_coverage_summary.py index 7c674c82..14a67465 100644 --- a/.github/scripts/quality/build_coverage_summary.py +++ b/.github/scripts/quality/build_coverage_summary.py @@ -7,6 +7,13 @@ MetricCounts = dict[str, tuple[int, int]] +METRICS: list[tuple[str, str]] = [ + ("LINE", "Line"), + ("INSTRUCTION", "Instruction"), + ("BRANCH", "Branch"), + ("METHOD", "Method"), + ("CLASS", "Class"), +] def read_counters(report: Path) -> MetricCounts | None: @@ -31,20 +38,33 @@ def read_counters(report: Path) -> MetricCounts | None: def format_ratio(covered: int, missed: int) -> str: total = covered + missed if total == 0: - return "0.00%" + return "n/a" return f"{covered / total * 100:.2f}%" -def main() -> int: - if len(sys.argv) != 3: - print("usage: build_coverage_summary.py ", file=sys.stderr) - return 2 - - reports_root = Path(sys.argv[1]) - output_file = Path(sys.argv[2]) - - module_rows: list[tuple[str, MetricCounts]] = [] - fallback_rows: list[tuple[str, MetricCounts]] = [] +def format_metric_cell(counts: MetricCounts, metric: str) -> str: + covered, missed = counts.get(metric, (0, 0)) + total = covered + missed + if total == 0: + return "n/a (0/0)" + return f"{format_ratio(covered, missed)} ({covered}/{total})" + + +def discover_reports(reports_root: Path) -> tuple[MetricCounts | None, list[tuple[str, MetricCounts]]]: + aggregate_report = reports_root / "aggregate" / "report.xml" + if aggregate_report.exists(): + aggregate_counts = read_counters(aggregate_report) + module_rows: list[tuple[str, MetricCounts]] = [] + for report in sorted((reports_root / "modules").glob("*/report.xml")): + counts = read_counters(report) + if counts is None: + continue + module_rows.append((report.parent.name, counts)) + return aggregate_counts, module_rows + + aggregate_counts = None + module_rows = [] + fallback_rows = [] for report in sorted(reports_root.glob("**/build/reports/kover/report.xml")): counts = read_counters(report) @@ -54,54 +74,77 @@ def main() -> int: rel = report.relative_to(reports_root) parts = rel.parts - if len(parts) >= 5 and parts[1:5] == ("build", "reports", "kover", "report.xml"): + if parts == ("build", "reports", "kover", "report.xml"): + aggregate_counts = counts + elif len(parts) >= 5 and parts[1:5] == ("build", "reports", "kover", "report.xml"): module_rows.append((parts[0], counts)) else: fallback_rows.append((str(rel.parent), counts)) - rows = module_rows or fallback_rows - total_line_covered = sum(counts.get("LINE", (0, 0))[0] for _, counts in rows) - total_line_missed = sum(counts.get("LINE", (0, 0))[1] for _, counts in rows) - total_instruction_covered = sum(counts.get("INSTRUCTION", (0, 0))[0] for _, counts in rows) - total_instruction_missed = sum(counts.get("INSTRUCTION", (0, 0))[1] for _, counts in rows) - total_lines = total_line_covered + total_line_missed - total_instructions = total_instruction_covered + total_instruction_missed + return aggregate_counts, module_rows or fallback_rows + + +def main() -> int: + if len(sys.argv) != 3: + print("usage: build_coverage_summary.py ", file=sys.stderr) + return 2 + + reports_root = Path(sys.argv[1]) + output_file = Path(sys.argv[2]) + + aggregate_counts, rows = discover_reports(reports_root) + + if aggregate_counts is None and rows: + aggregate_counts = {} + for metric, _ in METRICS: + covered = sum(counts.get(metric, (0, 0))[0] for _, counts in rows) + missed = sum(counts.get(metric, (0, 0))[1] for _, counts in rows) + aggregate_counts[metric] = (covered, missed) lines = [ - "## Coverage", + "## Coverage Details", "", - ( - f"- Overall line coverage: {format_ratio(total_line_covered, total_line_missed)} " - f"({total_line_covered} covered / {total_lines} total)" - ), - ( - f"- Overall instruction coverage: " - f"{format_ratio(total_instruction_covered, total_instruction_missed)} " - f"({total_instruction_covered} covered / {total_instructions} total)" - ), ] + if aggregate_counts is not None: + lines.extend( + [ + "", + "### Overall", + "", + "| Metric | Coverage | Covered | Missed | Total |", + "| --- | ---: | ---: | ---: | ---: |", + ] + ) + + for metric, label in METRICS: + covered, missed = aggregate_counts.get(metric, (0, 0)) + total = covered + missed + lines.append( + f"| {label} | {format_ratio(covered, missed)} | {covered} | {missed} | {total} |" + ) + if rows: lines.extend( [ "", - "| Module | Line Coverage | Line Covered | Line Total | Instruction Coverage | Instruction Covered | Instruction Total |", - "| --- | ---: | ---: | ---: | ---: | ---: | ---: |", + "### By Module", + "", + "| Module | Line | Instruction | Branch | Method | Class |", + "| --- | ---: | ---: | ---: | ---: | ---: |", ] ) for module, counts in sorted(rows): - line_covered, line_missed = counts.get("LINE", (0, 0)) - instruction_covered, instruction_missed = counts.get("INSTRUCTION", (0, 0)) - line_total = line_covered + line_missed - instruction_total = instruction_covered + instruction_missed lines.append( - f"| `{module}` | {format_ratio(line_covered, line_missed)} | " - f"{line_covered} | {line_total} | " - f"{format_ratio(instruction_covered, instruction_missed)} | " - f"{instruction_covered} | {instruction_total} |" + f"| `{module}` | " + f"{format_metric_cell(counts, 'LINE')} | " + f"{format_metric_cell(counts, 'INSTRUCTION')} | " + f"{format_metric_cell(counts, 'BRANCH')} | " + f"{format_metric_cell(counts, 'METHOD')} | " + f"{format_metric_cell(counts, 'CLASS')} |" ) - else: + elif aggregate_counts is None: lines.extend(["", "- No Kover XML reports found"]) output_file.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/.github/scripts/quality/prepare_coverage_artifacts.sh b/.github/scripts/quality/prepare_coverage_artifacts.sh new file mode 100644 index 00000000..1455e130 --- /dev/null +++ b/.github/scripts/quality/prepare_coverage_artifacts.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +artifact_root="${1:-coverage-artifacts}" + +rm -rf "$artifact_root" + +mkdir -p \ + "$artifact_root/aggregate" \ + "$artifact_root/modules" + +if [[ -f build/reports/kover/report.xml ]]; then + cp build/reports/kover/report.xml "$artifact_root/aggregate/report.xml" +fi + +for module in ai app data domain; do + report_path="$module/build/reports/kover/report.xml" + if [[ -f "$report_path" ]]; then + mkdir -p "$artifact_root/modules/$module" + cp "$report_path" "$artifact_root/modules/$module/report.xml" + fi +done diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index 25e36859..a858ef85 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -66,7 +66,7 @@ jobs: detailed_summary: true - name: Load quality monitor coverage config - if: hashFiles('pr-artifacts/report.xml') != '' + if: hashFiles('pr-artifacts/coverage-artifacts/aggregate/report.xml') != '' id: quality-monitor-config shell: bash run: | @@ -77,7 +77,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Publish coverage report - if: hashFiles('pr-artifacts/report.xml') != '' + if: hashFiles('pr-artifacts/coverage-artifacts/aggregate/report.xml') != '' continue-on-error: true uses: uhafner/quality-monitor@v4.7.1 with: @@ -90,6 +90,22 @@ jobs: skip-annotations: true config: ${{ steps.quality-monitor-config.outputs.json }} + - name: Build coverage details comment + if: hashFiles('pr-artifacts/coverage-artifacts/aggregate/report.xml') != '' + shell: bash + run: | + python3 .github/scripts/quality/build_coverage_summary.py \ + pr-artifacts/coverage-artifacts \ + coverage-details.md + + - name: Publish coverage details comment + if: hashFiles('pr-artifacts/coverage-artifacts/aggregate/report.xml') != '' + uses: marocchino/sticky-pull-request-comment@v3.0.2 + with: + number: ${{ github.event.workflow_run.pull_requests[0].number }} + header: coverage-details + path: coverage-details.md + - name: Build PR summary shell: bash run: | diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index f08759ee..82137e50 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -248,14 +248,18 @@ jobs: shell: bash run: bash .github/scripts/quality/run_coverage.sh + - name: Prepare coverage artifacts + if: always() + shell: bash + run: bash .github/scripts/quality/prepare_coverage_artifacts.sh coverage-artifacts + - name: Upload coverage artifacts if: always() uses: actions/upload-artifact@v7.0.0 with: name: quality-coverage-reports if-no-files-found: ignore - path: | - build/reports/kover/** + path: coverage-artifacts - name: Publish coverage summary if: always() From c8081d60790f137a536a1c80a2c02fed063c4902 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sun, 22 Mar 2026 19:44:38 +0100 Subject: [PATCH 10/20] ci: fix detailed coverage report paths --- .github/workflows/pr-report.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index a858ef85..0804f42d 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -66,7 +66,7 @@ jobs: detailed_summary: true - name: Load quality monitor coverage config - if: hashFiles('pr-artifacts/coverage-artifacts/aggregate/report.xml') != '' + if: hashFiles('pr-artifacts/aggregate/report.xml') != '' id: quality-monitor-config shell: bash run: | @@ -77,7 +77,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Publish coverage report - if: hashFiles('pr-artifacts/coverage-artifacts/aggregate/report.xml') != '' + if: hashFiles('pr-artifacts/aggregate/report.xml') != '' continue-on-error: true uses: uhafner/quality-monitor@v4.7.1 with: @@ -91,15 +91,15 @@ jobs: config: ${{ steps.quality-monitor-config.outputs.json }} - name: Build coverage details comment - if: hashFiles('pr-artifacts/coverage-artifacts/aggregate/report.xml') != '' + if: hashFiles('pr-artifacts/aggregate/report.xml') != '' shell: bash run: | python3 .github/scripts/quality/build_coverage_summary.py \ - pr-artifacts/coverage-artifacts \ + pr-artifacts \ coverage-details.md - name: Publish coverage details comment - if: hashFiles('pr-artifacts/coverage-artifacts/aggregate/report.xml') != '' + if: hashFiles('pr-artifacts/aggregate/report.xml') != '' uses: marocchino/sticky-pull-request-comment@v3.0.2 with: number: ${{ github.event.workflow_run.pull_requests[0].number }} From 807918feb31363c95466fc232645d9836f3d4b09 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sun, 22 Mar 2026 20:09:04 +0100 Subject: [PATCH 11/20] ci: annotate uncovered coverage in pull requests --- .github/autograding/coverage-annotations.json | 92 +++++++++++++++++++ .github/workflows/pr-report.yml | 22 +++++ 2 files changed, 114 insertions(+) create mode 100644 .github/autograding/coverage-annotations.json diff --git a/.github/autograding/coverage-annotations.json b/.github/autograding/coverage-annotations.json new file mode 100644 index 00000000..7276b366 --- /dev/null +++ b/.github/autograding/coverage-annotations.json @@ -0,0 +1,92 @@ +{ + "coverage": [ + { + "name": "AI Coverage", + "maxScore": 100, + "coveredPercentageImpact": 1, + "missedPercentageImpact": 0, + "tools": [ + { + "id": "jacoco", + "name": "Line Coverage", + "metric": "line", + "sourcePath": "ai/src/main/java", + "pattern": "pr-artifacts/modules/ai/report.xml" + }, + { + "id": "jacoco", + "name": "Branch Coverage", + "metric": "branch", + "sourcePath": "ai/src/main/java", + "pattern": "pr-artifacts/modules/ai/report.xml" + } + ] + }, + { + "name": "App Coverage", + "maxScore": 100, + "coveredPercentageImpact": 1, + "missedPercentageImpact": 0, + "tools": [ + { + "id": "jacoco", + "name": "Line Coverage", + "metric": "line", + "sourcePath": "app/src/main/java", + "pattern": "pr-artifacts/modules/app/report.xml" + }, + { + "id": "jacoco", + "name": "Branch Coverage", + "metric": "branch", + "sourcePath": "app/src/main/java", + "pattern": "pr-artifacts/modules/app/report.xml" + } + ] + }, + { + "name": "Data Coverage", + "maxScore": 100, + "coveredPercentageImpact": 1, + "missedPercentageImpact": 0, + "tools": [ + { + "id": "jacoco", + "name": "Line Coverage", + "metric": "line", + "sourcePath": "data/src/main/java", + "pattern": "pr-artifacts/modules/data/report.xml" + }, + { + "id": "jacoco", + "name": "Branch Coverage", + "metric": "branch", + "sourcePath": "data/src/main/java", + "pattern": "pr-artifacts/modules/data/report.xml" + } + ] + }, + { + "name": "Domain Coverage", + "maxScore": 100, + "coveredPercentageImpact": 1, + "missedPercentageImpact": 0, + "tools": [ + { + "id": "jacoco", + "name": "Line Coverage", + "metric": "line", + "sourcePath": "domain/src/main/java", + "pattern": "pr-artifacts/modules/domain/report.xml" + }, + { + "id": "jacoco", + "name": "Branch Coverage", + "metric": "branch", + "sourcePath": "domain/src/main/java", + "pattern": "pr-artifacts/modules/domain/report.xml" + } + ] + } + ] +} diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index 0804f42d..fda903ea 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -90,6 +90,28 @@ jobs: skip-annotations: true config: ${{ steps.quality-monitor-config.outputs.json }} + - name: Load coverage annotations config + if: hashFiles('pr-artifacts/modules/*/report.xml') != '' + id: coverage-annotations-config + shell: bash + run: | + { + echo 'json<> "$GITHUB_OUTPUT" + + - name: Publish coverage annotations + if: hashFiles('pr-artifacts/modules/*/report.xml') != '' + continue-on-error: true + uses: uhafner/autograding-github-action@v6.0.1 + with: + github-token: ${{ github.token }} + sha: ${{ github.event.workflow_run.head_sha }} + checks-name: Coverage Annotations + max-coverage-comments: 200 + config: ${{ steps.coverage-annotations-config.outputs.json }} + - name: Build coverage details comment if: hashFiles('pr-artifacts/aggregate/report.xml') != '' shell: bash From 9a0dd572c20b7386d86b0a641ab697f64d444f3f Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sun, 22 Mar 2026 20:13:57 +0100 Subject: [PATCH 12/20] ci: fix coverage annotation matching --- .github/autograding/coverage-annotations.json | 76 ++----------------- 1 file changed, 5 insertions(+), 71 deletions(-) diff --git a/.github/autograding/coverage-annotations.json b/.github/autograding/coverage-annotations.json index 7276b366..697d4a3b 100644 --- a/.github/autograding/coverage-annotations.json +++ b/.github/autograding/coverage-annotations.json @@ -1,7 +1,7 @@ { "coverage": [ { - "name": "AI Coverage", + "name": "Coverage Annotations", "maxScore": 100, "coveredPercentageImpact": 1, "missedPercentageImpact": 0, @@ -10,81 +10,15 @@ "id": "jacoco", "name": "Line Coverage", "metric": "line", - "sourcePath": "ai/src/main/java", - "pattern": "pr-artifacts/modules/ai/report.xml" + "sourcePath": ".", + "pattern": "**/modules/*/report.xml" }, { "id": "jacoco", "name": "Branch Coverage", "metric": "branch", - "sourcePath": "ai/src/main/java", - "pattern": "pr-artifacts/modules/ai/report.xml" - } - ] - }, - { - "name": "App Coverage", - "maxScore": 100, - "coveredPercentageImpact": 1, - "missedPercentageImpact": 0, - "tools": [ - { - "id": "jacoco", - "name": "Line Coverage", - "metric": "line", - "sourcePath": "app/src/main/java", - "pattern": "pr-artifacts/modules/app/report.xml" - }, - { - "id": "jacoco", - "name": "Branch Coverage", - "metric": "branch", - "sourcePath": "app/src/main/java", - "pattern": "pr-artifacts/modules/app/report.xml" - } - ] - }, - { - "name": "Data Coverage", - "maxScore": 100, - "coveredPercentageImpact": 1, - "missedPercentageImpact": 0, - "tools": [ - { - "id": "jacoco", - "name": "Line Coverage", - "metric": "line", - "sourcePath": "data/src/main/java", - "pattern": "pr-artifacts/modules/data/report.xml" - }, - { - "id": "jacoco", - "name": "Branch Coverage", - "metric": "branch", - "sourcePath": "data/src/main/java", - "pattern": "pr-artifacts/modules/data/report.xml" - } - ] - }, - { - "name": "Domain Coverage", - "maxScore": 100, - "coveredPercentageImpact": 1, - "missedPercentageImpact": 0, - "tools": [ - { - "id": "jacoco", - "name": "Line Coverage", - "metric": "line", - "sourcePath": "domain/src/main/java", - "pattern": "pr-artifacts/modules/domain/report.xml" - }, - { - "id": "jacoco", - "name": "Branch Coverage", - "metric": "branch", - "sourcePath": "domain/src/main/java", - "pattern": "pr-artifacts/modules/domain/report.xml" + "sourcePath": ".", + "pattern": "**/modules/*/report.xml" } ] } From 43f3c5612897a99705085eb3df018a43213b7ed6 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sun, 22 Mar 2026 20:18:13 +0100 Subject: [PATCH 13/20] ci: attach coverage annotations to pr head --- .github/workflows/pr-report.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index fda903ea..0bf0d66f 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -105,11 +105,12 @@ jobs: if: hashFiles('pr-artifacts/modules/*/report.xml') != '' continue-on-error: true uses: uhafner/autograding-github-action@v6.0.1 + env: + GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} with: github-token: ${{ github.token }} - sha: ${{ github.event.workflow_run.head_sha }} checks-name: Coverage Annotations - max-coverage-comments: 200 + max-coverage-annotations: 200 config: ${{ steps.coverage-annotations-config.outputs.json }} - name: Build coverage details comment From 5b998db47685084a0a6bccf004a50ce687a21549 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sun, 22 Mar 2026 20:21:53 +0100 Subject: [PATCH 14/20] ci: override autograding target sha --- .github/workflows/pr-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index 0bf0d66f..ba4ed6ba 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -106,7 +106,7 @@ jobs: continue-on-error: true uses: uhafner/autograding-github-action@v6.0.1 env: - GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} + SHA: ${{ github.event.workflow_run.head_sha }} with: github-token: ${{ github.token }} checks-name: Coverage Annotations From 9bc0d8e979dac3e9bc3fa71c71a966a7fa0a2dec Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sun, 22 Mar 2026 21:15:47 +0100 Subject: [PATCH 15/20] ci: split coverage annotations by module --- ...ions.json => coverage-annotations-ai.json} | 10 +-- .../autograding/coverage-annotations-app.json | 26 ++++++ .../coverage-annotations-data.json | 26 ++++++ .../coverage-annotations-domain.json | 26 ++++++ .github/workflows/pr-report.yml | 85 +++++++++++++++++-- 5 files changed, 160 insertions(+), 13 deletions(-) rename .github/autograding/{coverage-annotations.json => coverage-annotations-ai.json} (62%) create mode 100644 .github/autograding/coverage-annotations-app.json create mode 100644 .github/autograding/coverage-annotations-data.json create mode 100644 .github/autograding/coverage-annotations-domain.json diff --git a/.github/autograding/coverage-annotations.json b/.github/autograding/coverage-annotations-ai.json similarity index 62% rename from .github/autograding/coverage-annotations.json rename to .github/autograding/coverage-annotations-ai.json index 697d4a3b..7e687933 100644 --- a/.github/autograding/coverage-annotations.json +++ b/.github/autograding/coverage-annotations-ai.json @@ -1,7 +1,7 @@ { "coverage": [ { - "name": "Coverage Annotations", + "name": "AI Coverage Annotations", "maxScore": 100, "coveredPercentageImpact": 1, "missedPercentageImpact": 0, @@ -10,15 +10,15 @@ "id": "jacoco", "name": "Line Coverage", "metric": "line", - "sourcePath": ".", - "pattern": "**/modules/*/report.xml" + "sourcePath": "ai/src/main/java", + "pattern": "**/modules/ai/report.xml" }, { "id": "jacoco", "name": "Branch Coverage", "metric": "branch", - "sourcePath": ".", - "pattern": "**/modules/*/report.xml" + "sourcePath": "ai/src/main/java", + "pattern": "**/modules/ai/report.xml" } ] } diff --git a/.github/autograding/coverage-annotations-app.json b/.github/autograding/coverage-annotations-app.json new file mode 100644 index 00000000..13deedf3 --- /dev/null +++ b/.github/autograding/coverage-annotations-app.json @@ -0,0 +1,26 @@ +{ + "coverage": [ + { + "name": "App Coverage Annotations", + "maxScore": 100, + "coveredPercentageImpact": 1, + "missedPercentageImpact": 0, + "tools": [ + { + "id": "jacoco", + "name": "Line Coverage", + "metric": "line", + "sourcePath": "app/src/main/java", + "pattern": "**/modules/app/report.xml" + }, + { + "id": "jacoco", + "name": "Branch Coverage", + "metric": "branch", + "sourcePath": "app/src/main/java", + "pattern": "**/modules/app/report.xml" + } + ] + } + ] +} diff --git a/.github/autograding/coverage-annotations-data.json b/.github/autograding/coverage-annotations-data.json new file mode 100644 index 00000000..f034dd8b --- /dev/null +++ b/.github/autograding/coverage-annotations-data.json @@ -0,0 +1,26 @@ +{ + "coverage": [ + { + "name": "Data Coverage Annotations", + "maxScore": 100, + "coveredPercentageImpact": 1, + "missedPercentageImpact": 0, + "tools": [ + { + "id": "jacoco", + "name": "Line Coverage", + "metric": "line", + "sourcePath": "data/src/main/java", + "pattern": "**/modules/data/report.xml" + }, + { + "id": "jacoco", + "name": "Branch Coverage", + "metric": "branch", + "sourcePath": "data/src/main/java", + "pattern": "**/modules/data/report.xml" + } + ] + } + ] +} diff --git a/.github/autograding/coverage-annotations-domain.json b/.github/autograding/coverage-annotations-domain.json new file mode 100644 index 00000000..12655a57 --- /dev/null +++ b/.github/autograding/coverage-annotations-domain.json @@ -0,0 +1,26 @@ +{ + "coverage": [ + { + "name": "Domain Coverage Annotations", + "maxScore": 100, + "coveredPercentageImpact": 1, + "missedPercentageImpact": 0, + "tools": [ + { + "id": "jacoco", + "name": "Line Coverage", + "metric": "line", + "sourcePath": "domain/src/main/java", + "pattern": "**/modules/domain/report.xml" + }, + { + "id": "jacoco", + "name": "Branch Coverage", + "metric": "branch", + "sourcePath": "domain/src/main/java", + "pattern": "**/modules/domain/report.xml" + } + ] + } + ] +} diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index ba4ed6ba..26900e94 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -90,28 +90,97 @@ jobs: skip-annotations: true config: ${{ steps.quality-monitor-config.outputs.json }} - - name: Load coverage annotations config - if: hashFiles('pr-artifacts/modules/*/report.xml') != '' - id: coverage-annotations-config + - name: Load AI coverage annotations config + if: hashFiles('pr-artifacts/modules/ai/report.xml') != '' + id: coverage-annotations-ai-config shell: bash run: | { echo 'json<> "$GITHUB_OUTPUT" - - name: Publish coverage annotations - if: hashFiles('pr-artifacts/modules/*/report.xml') != '' + - name: Publish AI coverage annotations + if: hashFiles('pr-artifacts/modules/ai/report.xml') != '' continue-on-error: true uses: uhafner/autograding-github-action@v6.0.1 env: SHA: ${{ github.event.workflow_run.head_sha }} with: github-token: ${{ github.token }} - checks-name: Coverage Annotations + checks-name: AI Coverage Annotations max-coverage-annotations: 200 - config: ${{ steps.coverage-annotations-config.outputs.json }} + config: ${{ steps.coverage-annotations-ai-config.outputs.json }} + + - name: Load App coverage annotations config + if: hashFiles('pr-artifacts/modules/app/report.xml') != '' + id: coverage-annotations-app-config + shell: bash + run: | + { + echo 'json<> "$GITHUB_OUTPUT" + + - name: Publish App coverage annotations + if: hashFiles('pr-artifacts/modules/app/report.xml') != '' + continue-on-error: true + uses: uhafner/autograding-github-action@v6.0.1 + env: + SHA: ${{ github.event.workflow_run.head_sha }} + with: + github-token: ${{ github.token }} + checks-name: App Coverage Annotations + max-coverage-annotations: 200 + config: ${{ steps.coverage-annotations-app-config.outputs.json }} + + - name: Load Data coverage annotations config + if: hashFiles('pr-artifacts/modules/data/report.xml') != '' + id: coverage-annotations-data-config + shell: bash + run: | + { + echo 'json<> "$GITHUB_OUTPUT" + + - name: Publish Data coverage annotations + if: hashFiles('pr-artifacts/modules/data/report.xml') != '' + continue-on-error: true + uses: uhafner/autograding-github-action@v6.0.1 + env: + SHA: ${{ github.event.workflow_run.head_sha }} + with: + github-token: ${{ github.token }} + checks-name: Data Coverage Annotations + max-coverage-annotations: 200 + config: ${{ steps.coverage-annotations-data-config.outputs.json }} + + - name: Load Domain coverage annotations config + if: hashFiles('pr-artifacts/modules/domain/report.xml') != '' + id: coverage-annotations-domain-config + shell: bash + run: | + { + echo 'json<> "$GITHUB_OUTPUT" + + - name: Publish Domain coverage annotations + if: hashFiles('pr-artifacts/modules/domain/report.xml') != '' + continue-on-error: true + uses: uhafner/autograding-github-action@v6.0.1 + env: + SHA: ${{ github.event.workflow_run.head_sha }} + with: + github-token: ${{ github.token }} + checks-name: Domain Coverage Annotations + max-coverage-annotations: 200 + config: ${{ steps.coverage-annotations-domain-config.outputs.json }} - name: Build coverage details comment if: hashFiles('pr-artifacts/aggregate/report.xml') != '' From 1030c293bdf82f2d3afd0f738dabded77a8405a5 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Sun, 22 Mar 2026 21:22:02 +0100 Subject: [PATCH 16/20] ci: set module source paths for coverage annotations --- .github/autograding/coverage-annotations-ai.json | 1 + .github/autograding/coverage-annotations-app.json | 1 + .github/autograding/coverage-annotations-data.json | 1 + .github/autograding/coverage-annotations-domain.json | 1 + 4 files changed, 4 insertions(+) diff --git a/.github/autograding/coverage-annotations-ai.json b/.github/autograding/coverage-annotations-ai.json index 7e687933..276025b5 100644 --- a/.github/autograding/coverage-annotations-ai.json +++ b/.github/autograding/coverage-annotations-ai.json @@ -2,6 +2,7 @@ "coverage": [ { "name": "AI Coverage Annotations", + "sourcePath": "ai/src/main/java", "maxScore": 100, "coveredPercentageImpact": 1, "missedPercentageImpact": 0, diff --git a/.github/autograding/coverage-annotations-app.json b/.github/autograding/coverage-annotations-app.json index 13deedf3..065dec84 100644 --- a/.github/autograding/coverage-annotations-app.json +++ b/.github/autograding/coverage-annotations-app.json @@ -2,6 +2,7 @@ "coverage": [ { "name": "App Coverage Annotations", + "sourcePath": "app/src/main/java", "maxScore": 100, "coveredPercentageImpact": 1, "missedPercentageImpact": 0, diff --git a/.github/autograding/coverage-annotations-data.json b/.github/autograding/coverage-annotations-data.json index f034dd8b..1e9296cb 100644 --- a/.github/autograding/coverage-annotations-data.json +++ b/.github/autograding/coverage-annotations-data.json @@ -2,6 +2,7 @@ "coverage": [ { "name": "Data Coverage Annotations", + "sourcePath": "data/src/main/java", "maxScore": 100, "coveredPercentageImpact": 1, "missedPercentageImpact": 0, diff --git a/.github/autograding/coverage-annotations-domain.json b/.github/autograding/coverage-annotations-domain.json index 12655a57..c28d3659 100644 --- a/.github/autograding/coverage-annotations-domain.json +++ b/.github/autograding/coverage-annotations-domain.json @@ -2,6 +2,7 @@ "coverage": [ { "name": "Domain Coverage Annotations", + "sourcePath": "domain/src/main/java", "maxScore": 100, "coveredPercentageImpact": 1, "missedPercentageImpact": 0, From 9d051306616776c066a75ba95e3423544af301a1 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Mon, 23 Mar 2026 01:15:56 +0100 Subject: [PATCH 17/20] ci: publish uncovered coverage comment --- .../build_uncovered_coverage_comment.py | 120 ++++++++++++++++++ .github/workflows/pr-report.yml | 16 +++ 2 files changed, 136 insertions(+) create mode 100644 .github/scripts/quality/build_uncovered_coverage_comment.py diff --git a/.github/scripts/quality/build_uncovered_coverage_comment.py b/.github/scripts/quality/build_uncovered_coverage_comment.py new file mode 100644 index 00000000..45ec90f1 --- /dev/null +++ b/.github/scripts/quality/build_uncovered_coverage_comment.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def collect_uncovered_ranges(report: Path, repo_root: Path) -> list[tuple[str, list[str]]]: + try: + root = ET.parse(report).getroot() + except ET.ParseError: + return [] + + module = report.parent.name + uncovered: list[tuple[str, list[str]]] = [] + + for package in root.findall("./package"): + package_name = package.attrib.get("name", "").strip("/") + for sourcefile in package.findall("./sourcefile"): + source_name = sourcefile.attrib.get("name") + if not source_name: + continue + + lines = [] + for line in sourcefile.findall("./line"): + missed = int(line.attrib.get("mi", "0")) + covered = int(line.attrib.get("ci", "0")) + if missed > 0 and covered == 0: + lines.append(int(line.attrib["nr"])) + + if not lines: + continue + + file_path = resolve_source_path(repo_root, module, package_name, source_name) + uncovered.append((file_path.as_posix(), collapse_ranges(sorted(lines)))) + + return uncovered + + +def resolve_source_path(repo_root: Path, module: str, package_name: str, source_name: str) -> Path: + package_path = Path(package_name) if package_name else Path() + candidates = [ + repo_root / module / "src" / "main" / "java" / package_path / source_name, + repo_root / module / "src" / "main" / "kotlin" / package_path / source_name, + repo_root / module / "src" / "commonMain" / "kotlin" / package_path / source_name, + ] + + for candidate in candidates: + if candidate.exists(): + return candidate.relative_to(repo_root) + + return Path(module) / "src" / "main" / "java" / package_path / source_name + + +def collapse_ranges(lines: list[int]) -> list[str]: + ranges: list[str] = [] + if not lines: + return ranges + + start = lines[0] + end = lines[0] + + for current in lines[1:]: + if current == end + 1: + end = current + continue + ranges.append(format_range(start, end)) + start = current + end = current + + ranges.append(format_range(start, end)) + return ranges + + +def format_range(start: int, end: int) -> str: + if start == end: + return str(start) + return f"{start}-{end}" + + +def main() -> int: + if len(sys.argv) != 3: + print( + "usage: build_uncovered_coverage_comment.py ", + file=sys.stderr, + ) + return 2 + + reports_root = Path(sys.argv[1]) + output_file = Path(sys.argv[2]) + repo_root = Path.cwd() + + items: list[tuple[str, list[str]]] = [] + for report in sorted((reports_root / "modules").glob("*/report.xml")): + items.extend(collect_uncovered_ranges(report, repo_root)) + + lines = [ + "## Uncovered Code", + "", + ] + + if not items: + lines.append("- No completely uncovered line ranges found in the published module reports.") + else: + lines.extend( + [ + "| File | Uncovered Lines |", + "| --- | --- |", + ] + ) + for file_path, ranges in items: + lines.append(f"| `{file_path}` | `{', '.join(ranges)}` |") + + output_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index 26900e94..a6bd375e 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -198,6 +198,22 @@ jobs: header: coverage-details path: coverage-details.md + - name: Build uncovered coverage comment + if: hashFiles('pr-artifacts/modules/*/report.xml') != '' + shell: bash + run: | + python3 .github/scripts/quality/build_uncovered_coverage_comment.py \ + pr-artifacts \ + coverage-uncovered.md + + - name: Publish uncovered coverage comment + if: hashFiles('pr-artifacts/modules/*/report.xml') != '' + uses: marocchino/sticky-pull-request-comment@v3.0.2 + with: + number: ${{ github.event.workflow_run.pull_requests[0].number }} + header: coverage-uncovered + path: coverage-uncovered.md + - name: Build PR summary shell: bash run: | From ccbadf6b0921920d8d13097ea1725dc940603826 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Mon, 23 Mar 2026 01:22:23 +0100 Subject: [PATCH 18/20] ci: link uncovered coverage ranges to source --- .../build_uncovered_coverage_comment.py | 34 +++++++++++++++++-- .github/workflows/pr-report.yml | 4 ++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/scripts/quality/build_uncovered_coverage_comment.py b/.github/scripts/quality/build_uncovered_coverage_comment.py index 45ec90f1..0d3a7fc9 100644 --- a/.github/scripts/quality/build_uncovered_coverage_comment.py +++ b/.github/scripts/quality/build_uncovered_coverage_comment.py @@ -79,16 +79,40 @@ def format_range(start: int, end: int) -> str: return f"{start}-{end}" +def build_blob_url(repo: str, sha: str, file_path: str, line_range: str) -> str: + if "-" in line_range: + start, end = line_range.split("-", 1) + anchor = f"#L{start}-L{end}" + else: + anchor = f"#L{line_range}" + return f"https://github.com/{repo}/blob/{sha}/{file_path}{anchor}" + + +def render_file_cell(repo: str, sha: str, file_path: str) -> str: + if not repo or not sha: + return f"`{file_path}`" + return f"[`{file_path}`](https://github.com/{repo}/blob/{sha}/{file_path})" + + +def render_ranges_cell(repo: str, sha: str, file_path: str, ranges: list[str]) -> str: + if not repo or not sha: + return f"`{', '.join(ranges)}`" + links = [f"[`{line_range}`]({build_blob_url(repo, sha, file_path, line_range)})" for line_range in ranges] + return ", ".join(links) + + def main() -> int: - if len(sys.argv) != 3: + if len(sys.argv) not in {3, 5}: print( - "usage: build_uncovered_coverage_comment.py ", + "usage: build_uncovered_coverage_comment.py [repo sha]", file=sys.stderr, ) return 2 reports_root = Path(sys.argv[1]) output_file = Path(sys.argv[2]) + repo = sys.argv[3] if len(sys.argv) == 5 else "" + sha = sys.argv[4] if len(sys.argv) == 5 else "" repo_root = Path.cwd() items: list[tuple[str, list[str]]] = [] @@ -105,12 +129,16 @@ def main() -> int: else: lines.extend( [ + "The links below point to the exact file and line ranges in the PR head commit.", + "", "| File | Uncovered Lines |", "| --- | --- |", ] ) for file_path, ranges in items: - lines.append(f"| `{file_path}` | `{', '.join(ranges)}` |") + lines.append( + f"| {render_file_cell(repo, sha, file_path)} | {render_ranges_cell(repo, sha, file_path, ranges)} |" + ) output_file.write_text("\n".join(lines) + "\n", encoding="utf-8") return 0 diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index a6bd375e..0fa0ed57 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -204,7 +204,9 @@ jobs: run: | python3 .github/scripts/quality/build_uncovered_coverage_comment.py \ pr-artifacts \ - coverage-uncovered.md + coverage-uncovered.md \ + "${{ github.repository }}" \ + "${{ github.event.workflow_run.head_sha }}" - name: Publish uncovered coverage comment if: hashFiles('pr-artifacts/modules/*/report.xml') != '' From d64f6490c3fddd8521c8d460bcd3123f39fa7455 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Mon, 23 Mar 2026 01:25:45 +0100 Subject: [PATCH 19/20] ci: render uncovered code snippets in pr comments --- .../build_uncovered_coverage_comment.py | 62 ++++++++++++++++++- .github/workflows/pr-report.yml | 2 + 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/.github/scripts/quality/build_uncovered_coverage_comment.py b/.github/scripts/quality/build_uncovered_coverage_comment.py index 0d3a7fc9..53506cc4 100644 --- a/.github/scripts/quality/build_uncovered_coverage_comment.py +++ b/.github/scripts/quality/build_uncovered_coverage_comment.py @@ -6,6 +6,10 @@ from pathlib import Path +MAX_FILES = 10 +MAX_RANGES_PER_FILE = 5 + + def collect_uncovered_ranges(report: Path, repo_root: Path) -> list[tuple[str, list[str]]]: try: root = ET.parse(report).getroot() @@ -101,6 +105,41 @@ def render_ranges_cell(repo: str, sha: str, file_path: str, ranges: list[str]) - return ", ".join(links) +def detect_language(file_path: str) -> str: + suffix = Path(file_path).suffix.lower() + if suffix == ".kt": + return "kotlin" + if suffix == ".java": + return "java" + return "text" + + +def parse_range(line_range: str) -> tuple[int, int]: + if "-" in line_range: + start, end = line_range.split("-", 1) + return int(start), int(end) + number = int(line_range) + return number, number + + +def render_snippet(repo_root: Path, file_path: str, line_range: str) -> str: + start, end = parse_range(line_range) + file_on_disk = repo_root / file_path + if not file_on_disk.exists(): + return "" + + content = file_on_disk.read_text(encoding="utf-8").splitlines() + start_index = max(start - 1, 0) + end_index = min(end, len(content)) + + snippet_lines = [ + f"{line_number:>4} | {content[line_number - 1]}" + for line_number in range(start_index + 1, end_index + 1) + ] + language = detect_language(file_path) + return "\n".join([f"```{language}", *snippet_lines, "```"]) + + def main() -> int: if len(sys.argv) not in {3, 5}: print( @@ -127,6 +166,7 @@ def main() -> int: if not items: lines.append("- No completely uncovered line ranges found in the published module reports.") else: + selected_items = items[:MAX_FILES] lines.extend( [ "The links below point to the exact file and line ranges in the PR head commit.", @@ -135,11 +175,29 @@ def main() -> int: "| --- | --- |", ] ) - for file_path, ranges in items: + for file_path, ranges in selected_items: + limited_ranges = ranges[:MAX_RANGES_PER_FILE] lines.append( - f"| {render_file_cell(repo, sha, file_path)} | {render_ranges_cell(repo, sha, file_path, ranges)} |" + f"| {render_file_cell(repo, sha, file_path)} | " + f"{render_ranges_cell(repo, sha, file_path, limited_ranges)} |" ) + lines.extend(["", "### Code Snippets", ""]) + for file_path, ranges in selected_items: + limited_ranges = ranges[:MAX_RANGES_PER_FILE] + lines.append(f"#### {render_file_cell(repo, sha, file_path)}") + lines.append("") + for line_range in limited_ranges: + if repo and sha: + lines.append(f"- Range {render_ranges_cell(repo, sha, file_path, [line_range])}") + else: + lines.append(f"- Range `{line_range}`") + snippet = render_snippet(repo_root, file_path, line_range) + if snippet: + lines.append("") + lines.append(snippet) + lines.append("") + output_file.write_text("\n".join(lines) + "\n", encoding="utf-8") return 0 diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index 0fa0ed57..c1e60ee1 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6.0.2 + with: + ref: ${{ github.event.workflow_run.head_sha }} - name: Download workflow artifacts uses: actions/download-artifact@v8.0.1 From 2a44a2fb7235cc64906227768fe4673c2690b439 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Thu, 26 Mar 2026 14:11:49 +0100 Subject: [PATCH 20/20] ci: simplify coverage artifact reporting --- .../autograding/coverage-annotations-ai.json | 4 +- .../autograding/coverage-annotations-app.json | 4 +- .../coverage-annotations-data.json | 4 +- .../coverage-annotations-domain.json | 4 +- .github/quality-monitor/coverage-config.json | 4 +- .../scripts/quality/build_coverage_summary.py | 28 +++++++---- .../build_uncovered_coverage_comment.py | 8 +-- .../quality/prepare_coverage_artifacts.sh | 22 -------- .github/workflows/pr-report.yml | 50 +++++++++++++------ .github/workflows/quality.yml | 12 ++--- 10 files changed, 74 insertions(+), 66 deletions(-) delete mode 100644 .github/scripts/quality/prepare_coverage_artifacts.sh diff --git a/.github/autograding/coverage-annotations-ai.json b/.github/autograding/coverage-annotations-ai.json index 276025b5..3cbcc154 100644 --- a/.github/autograding/coverage-annotations-ai.json +++ b/.github/autograding/coverage-annotations-ai.json @@ -12,14 +12,14 @@ "name": "Line Coverage", "metric": "line", "sourcePath": "ai/src/main/java", - "pattern": "**/modules/ai/report.xml" + "pattern": "**/pr-artifacts/ai/build/reports/kover/report.xml" }, { "id": "jacoco", "name": "Branch Coverage", "metric": "branch", "sourcePath": "ai/src/main/java", - "pattern": "**/modules/ai/report.xml" + "pattern": "**/pr-artifacts/ai/build/reports/kover/report.xml" } ] } diff --git a/.github/autograding/coverage-annotations-app.json b/.github/autograding/coverage-annotations-app.json index 065dec84..c115eaaf 100644 --- a/.github/autograding/coverage-annotations-app.json +++ b/.github/autograding/coverage-annotations-app.json @@ -12,14 +12,14 @@ "name": "Line Coverage", "metric": "line", "sourcePath": "app/src/main/java", - "pattern": "**/modules/app/report.xml" + "pattern": "**/pr-artifacts/app/build/reports/kover/report.xml" }, { "id": "jacoco", "name": "Branch Coverage", "metric": "branch", "sourcePath": "app/src/main/java", - "pattern": "**/modules/app/report.xml" + "pattern": "**/pr-artifacts/app/build/reports/kover/report.xml" } ] } diff --git a/.github/autograding/coverage-annotations-data.json b/.github/autograding/coverage-annotations-data.json index 1e9296cb..fd27fae0 100644 --- a/.github/autograding/coverage-annotations-data.json +++ b/.github/autograding/coverage-annotations-data.json @@ -12,14 +12,14 @@ "name": "Line Coverage", "metric": "line", "sourcePath": "data/src/main/java", - "pattern": "**/modules/data/report.xml" + "pattern": "**/pr-artifacts/data/build/reports/kover/report.xml" }, { "id": "jacoco", "name": "Branch Coverage", "metric": "branch", "sourcePath": "data/src/main/java", - "pattern": "**/modules/data/report.xml" + "pattern": "**/pr-artifacts/data/build/reports/kover/report.xml" } ] } diff --git a/.github/autograding/coverage-annotations-domain.json b/.github/autograding/coverage-annotations-domain.json index c28d3659..a3e04870 100644 --- a/.github/autograding/coverage-annotations-domain.json +++ b/.github/autograding/coverage-annotations-domain.json @@ -12,14 +12,14 @@ "name": "Line Coverage", "metric": "line", "sourcePath": "domain/src/main/java", - "pattern": "**/modules/domain/report.xml" + "pattern": "**/pr-artifacts/domain/build/reports/kover/report.xml" }, { "id": "jacoco", "name": "Branch Coverage", "metric": "branch", "sourcePath": "domain/src/main/java", - "pattern": "**/modules/domain/report.xml" + "pattern": "**/pr-artifacts/domain/build/reports/kover/report.xml" } ] } diff --git a/.github/quality-monitor/coverage-config.json b/.github/quality-monitor/coverage-config.json index e3896f37..c10bcf0f 100644 --- a/.github/quality-monitor/coverage-config.json +++ b/.github/quality-monitor/coverage-config.json @@ -6,7 +6,7 @@ { "id": "jacoco", "metric": "line", - "pattern": "**/aggregate/report.xml" + "pattern": "**/pr-artifacts/build/reports/kover/report.xml" } ] }, @@ -16,7 +16,7 @@ { "id": "jacoco", "metric": "instruction", - "pattern": "**/aggregate/report.xml" + "pattern": "**/pr-artifacts/build/reports/kover/report.xml" } ] } diff --git a/.github/scripts/quality/build_coverage_summary.py b/.github/scripts/quality/build_coverage_summary.py index 14a67465..b0d9a5b5 100644 --- a/.github/scripts/quality/build_coverage_summary.py +++ b/.github/scripts/quality/build_coverage_summary.py @@ -50,16 +50,20 @@ def format_metric_cell(counts: MetricCounts, metric: str) -> str: return f"{format_ratio(covered, missed)} ({covered}/{total})" +def render_table_row(cells: list[str]) -> str: + return f"| {' | '.join(cells)} |" + + def discover_reports(reports_root: Path) -> tuple[MetricCounts | None, list[tuple[str, MetricCounts]]]: - aggregate_report = reports_root / "aggregate" / "report.xml" + aggregate_report = reports_root / "build" / "reports" / "kover" / "report.xml" if aggregate_report.exists(): aggregate_counts = read_counters(aggregate_report) module_rows: list[tuple[str, MetricCounts]] = [] - for report in sorted((reports_root / "modules").glob("*/report.xml")): + for report in reports_root.glob("*/build/reports/kover/report.xml"): counts = read_counters(report) if counts is None: continue - module_rows.append((report.parent.name, counts)) + module_rows.append((report.relative_to(reports_root).parts[0], counts)) return aggregate_counts, module_rows aggregate_counts = None @@ -121,7 +125,7 @@ def main() -> int: covered, missed = aggregate_counts.get(metric, (0, 0)) total = covered + missed lines.append( - f"| {label} | {format_ratio(covered, missed)} | {covered} | {missed} | {total} |" + render_table_row([label, format_ratio(covered, missed), str(covered), str(missed), str(total)]) ) if rows: @@ -137,12 +141,16 @@ def main() -> int: for module, counts in sorted(rows): lines.append( - f"| `{module}` | " - f"{format_metric_cell(counts, 'LINE')} | " - f"{format_metric_cell(counts, 'INSTRUCTION')} | " - f"{format_metric_cell(counts, 'BRANCH')} | " - f"{format_metric_cell(counts, 'METHOD')} | " - f"{format_metric_cell(counts, 'CLASS')} |" + render_table_row( + [ + f"`{module}`", + format_metric_cell(counts, "LINE"), + format_metric_cell(counts, "INSTRUCTION"), + format_metric_cell(counts, "BRANCH"), + format_metric_cell(counts, "METHOD"), + format_metric_cell(counts, "CLASS"), + ] + ) ) elif aggregate_counts is None: lines.extend(["", "- No Kover XML reports found"]) diff --git a/.github/scripts/quality/build_uncovered_coverage_comment.py b/.github/scripts/quality/build_uncovered_coverage_comment.py index 53506cc4..fedf4072 100644 --- a/.github/scripts/quality/build_uncovered_coverage_comment.py +++ b/.github/scripts/quality/build_uncovered_coverage_comment.py @@ -10,13 +10,12 @@ MAX_RANGES_PER_FILE = 5 -def collect_uncovered_ranges(report: Path, repo_root: Path) -> list[tuple[str, list[str]]]: +def collect_uncovered_ranges(report: Path, module: str, repo_root: Path) -> list[tuple[str, list[str]]]: try: root = ET.parse(report).getroot() except ET.ParseError: return [] - module = report.parent.name uncovered: list[tuple[str, list[str]]] = [] for package in root.findall("./package"): @@ -155,8 +154,9 @@ def main() -> int: repo_root = Path.cwd() items: list[tuple[str, list[str]]] = [] - for report in sorted((reports_root / "modules").glob("*/report.xml")): - items.extend(collect_uncovered_ranges(report, repo_root)) + for report in sorted(reports_root.glob("*/build/reports/kover/report.xml")): + module = report.relative_to(reports_root).parts[0] + items.extend(collect_uncovered_ranges(report, module, repo_root)) lines = [ "## Uncovered Code", diff --git a/.github/scripts/quality/prepare_coverage_artifacts.sh b/.github/scripts/quality/prepare_coverage_artifacts.sh deleted file mode 100644 index 1455e130..00000000 --- a/.github/scripts/quality/prepare_coverage_artifacts.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -artifact_root="${1:-coverage-artifacts}" - -rm -rf "$artifact_root" - -mkdir -p \ - "$artifact_root/aggregate" \ - "$artifact_root/modules" - -if [[ -f build/reports/kover/report.xml ]]; then - cp build/reports/kover/report.xml "$artifact_root/aggregate/report.xml" -fi - -for module in ai app data domain; do - report_path="$module/build/reports/kover/report.xml" - if [[ -f "$report_path" ]]; then - mkdir -p "$artifact_root/modules/$module" - cp "$report_path" "$artifact_root/modules/$module/report.xml" - fi -done diff --git a/.github/workflows/pr-report.yml b/.github/workflows/pr-report.yml index c1e60ee1..25759e6c 100644 --- a/.github/workflows/pr-report.yml +++ b/.github/workflows/pr-report.yml @@ -39,6 +39,28 @@ jobs: shell: bash run: bash .github/scripts/pr/list_downloaded_artifacts.sh pr-artifacts + - name: Detect coverage reports + id: coverage-reports + shell: bash + run: | + aggregate=false + modules=false + if [[ -f pr-artifacts/build/reports/kover/report.xml ]]; then + aggregate=true + fi + echo "aggregate=$aggregate" >> "$GITHUB_OUTPUT" + + for module in ai app data domain; do + present=false + if [[ -f "pr-artifacts/$module/build/reports/kover/report.xml" ]]; then + present=true + modules=true + fi + echo "$module=$present" >> "$GITHUB_OUTPUT" + done + + echo "modules=$modules" >> "$GITHUB_OUTPUT" + - name: Publish unit test report if: hashFiles('pr-artifacts/**/build/test-results/testDebugUnitTest/*.xml') != '' continue-on-error: true @@ -68,7 +90,7 @@ jobs: detailed_summary: true - name: Load quality monitor coverage config - if: hashFiles('pr-artifacts/aggregate/report.xml') != '' + if: steps.coverage-reports.outputs.aggregate == 'true' id: quality-monitor-config shell: bash run: | @@ -79,7 +101,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Publish coverage report - if: hashFiles('pr-artifacts/aggregate/report.xml') != '' + if: steps.coverage-reports.outputs.aggregate == 'true' continue-on-error: true uses: uhafner/quality-monitor@v4.7.1 with: @@ -93,7 +115,7 @@ jobs: config: ${{ steps.quality-monitor-config.outputs.json }} - name: Load AI coverage annotations config - if: hashFiles('pr-artifacts/modules/ai/report.xml') != '' + if: steps.coverage-reports.outputs.ai == 'true' id: coverage-annotations-ai-config shell: bash run: | @@ -104,7 +126,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Publish AI coverage annotations - if: hashFiles('pr-artifacts/modules/ai/report.xml') != '' + if: steps.coverage-reports.outputs.ai == 'true' continue-on-error: true uses: uhafner/autograding-github-action@v6.0.1 env: @@ -116,7 +138,7 @@ jobs: config: ${{ steps.coverage-annotations-ai-config.outputs.json }} - name: Load App coverage annotations config - if: hashFiles('pr-artifacts/modules/app/report.xml') != '' + if: steps.coverage-reports.outputs.app == 'true' id: coverage-annotations-app-config shell: bash run: | @@ -127,7 +149,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Publish App coverage annotations - if: hashFiles('pr-artifacts/modules/app/report.xml') != '' + if: steps.coverage-reports.outputs.app == 'true' continue-on-error: true uses: uhafner/autograding-github-action@v6.0.1 env: @@ -139,7 +161,7 @@ jobs: config: ${{ steps.coverage-annotations-app-config.outputs.json }} - name: Load Data coverage annotations config - if: hashFiles('pr-artifacts/modules/data/report.xml') != '' + if: steps.coverage-reports.outputs.data == 'true' id: coverage-annotations-data-config shell: bash run: | @@ -150,7 +172,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Publish Data coverage annotations - if: hashFiles('pr-artifacts/modules/data/report.xml') != '' + if: steps.coverage-reports.outputs.data == 'true' continue-on-error: true uses: uhafner/autograding-github-action@v6.0.1 env: @@ -162,7 +184,7 @@ jobs: config: ${{ steps.coverage-annotations-data-config.outputs.json }} - name: Load Domain coverage annotations config - if: hashFiles('pr-artifacts/modules/domain/report.xml') != '' + if: steps.coverage-reports.outputs.domain == 'true' id: coverage-annotations-domain-config shell: bash run: | @@ -173,7 +195,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Publish Domain coverage annotations - if: hashFiles('pr-artifacts/modules/domain/report.xml') != '' + if: steps.coverage-reports.outputs.domain == 'true' continue-on-error: true uses: uhafner/autograding-github-action@v6.0.1 env: @@ -185,7 +207,7 @@ jobs: config: ${{ steps.coverage-annotations-domain-config.outputs.json }} - name: Build coverage details comment - if: hashFiles('pr-artifacts/aggregate/report.xml') != '' + if: steps.coverage-reports.outputs.aggregate == 'true' shell: bash run: | python3 .github/scripts/quality/build_coverage_summary.py \ @@ -193,7 +215,7 @@ jobs: coverage-details.md - name: Publish coverage details comment - if: hashFiles('pr-artifacts/aggregate/report.xml') != '' + if: steps.coverage-reports.outputs.aggregate == 'true' uses: marocchino/sticky-pull-request-comment@v3.0.2 with: number: ${{ github.event.workflow_run.pull_requests[0].number }} @@ -201,7 +223,7 @@ jobs: path: coverage-details.md - name: Build uncovered coverage comment - if: hashFiles('pr-artifacts/modules/*/report.xml') != '' + if: steps.coverage-reports.outputs.modules == 'true' shell: bash run: | python3 .github/scripts/quality/build_uncovered_coverage_comment.py \ @@ -211,7 +233,7 @@ jobs: "${{ github.event.workflow_run.head_sha }}" - name: Publish uncovered coverage comment - if: hashFiles('pr-artifacts/modules/*/report.xml') != '' + if: steps.coverage-reports.outputs.modules == 'true' uses: marocchino/sticky-pull-request-comment@v3.0.2 with: number: ${{ github.event.workflow_run.pull_requests[0].number }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 82137e50..d6f349b7 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -248,18 +248,18 @@ jobs: shell: bash run: bash .github/scripts/quality/run_coverage.sh - - name: Prepare coverage artifacts - if: always() - shell: bash - run: bash .github/scripts/quality/prepare_coverage_artifacts.sh coverage-artifacts - - name: Upload coverage artifacts if: always() uses: actions/upload-artifact@v7.0.0 with: name: quality-coverage-reports if-no-files-found: ignore - path: coverage-artifacts + path: | + build/reports/kover/report.xml + ai/build/reports/kover/report.xml + app/build/reports/kover/report.xml + data/build/reports/kover/report.xml + domain/build/reports/kover/report.xml - name: Publish coverage summary if: always()