Skip to content

Commit

Permalink
Add script to generate SonarCloud test coverage report (#222)
Browse files Browse the repository at this point in the history
Add script to generate SonarCloud test coverage report

Signed-off-by: Kuba Odias <[email protected]>
  • Loading branch information
Kuba Odias authored May 26, 2023
1 parent c903509 commit b63c03a
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ This repository holds integrations, examples, and proof-of-concepts that work wi
- [Kubernetes API Client](./k8s_api_client)
- [Grafana Dashboard](./grafana-dashboard)
- [OpenAPI Specification for OPA](./open_api)
- [SonarCloud Test Coverage Conversion](./sonarcloud)

For a comprehensive list of integrations, see the OPA [ecosystem](https://www.openpolicyagent.org/docs/latest/ecosystem/) page.

Expand Down
32 changes: 32 additions & 0 deletions sonarcloud/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# OPA Test Coverage to SonarCloud Format

Example code to transform the OPA test coverage JSON report to
the [SonarCloud](https://docs.sonarqube.org/latest/analyzing-source-code/test-coverage/generic-test-data/) coverage report format.

## Why?

SonarCloud allows collecting test coverage data to generate rich reports. Integration can be done using [generic test data format](https://docs.sonarqube.org/latest/analyzing-source-code/test-coverage/generic-test-data/).

## Example
1. generate json format coverage result with `--coverage` and pipe it into a file
2. call `python opa_coverage_to_sonarcloud.py <input json> <output xml>` to generate xml format coverage report
```shell
$ opa test --coverage example > coverage.json
$ python opa_coverage_to_sonarcloud.py coverage.json coverage.xml
```
**Output**
```xml
<?xml version='1.0' encoding='utf-8'?>
<coverage version="1">
<file path="policy.rego">
<lineToCover lineNumber="3" covered="true" />
<lineToCover lineNumber="4" covered="true" />
<lineToCover lineNumber="7" covered="false" />
<lineToCover lineNumber="8" covered="false" />
</file>
<file path="policy_test.rego">
<lineToCover lineNumber="3" covered="true" />
<lineToCover lineNumber="4" covered="true" />
</file>
</coverage>
```
9 changes: 9 additions & 0 deletions sonarcloud/example/policy.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package policy

deny["foo"] {
input.foo == "bar"
}

deny["bar"] {
input.bar == "foo"
}
5 changes: 5 additions & 0 deletions sonarcloud/example/policy_test.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package policy

test_deny {
deny with input as {"foo": "bar"}
}
104 changes: 104 additions & 0 deletions sonarcloud/opa_coverage_to_sonarcloud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import argparse
from dataclasses import dataclass, field
from typing import List
import json
import xml.etree.ElementTree as ET
import os
import time
import math


@dataclass
class RowInfo:
row: int


@dataclass
class LineCoverage:
start: RowInfo
end: RowInfo


@dataclass
class BaseCoverage:
coverage: int
covered_lines: int = 0
not_covered_lines: int = 0


@dataclass
class FileCoverage(BaseCoverage):
covered: List[LineCoverage] = field(default_factory=list)
not_covered: List[LineCoverage] = field(default_factory=list)


@dataclass
class OPACoverage(BaseCoverage):
files: dict[str, FileCoverage] = field(default_factory=dict)

@property
def lines_valid(self):
return self.covered_lines + self.not_covered_lines


def generate_sonarcloud_xml(coverage: OPACoverage) -> ET.Element:
coverage_xml = ET.Element("coverage", attrib={
"version": "1",
})
for path, data in coverage.files.items():
if isinstance(data, dict):
if not "coverage" in data:
data["coverage"] = 0
data = FileCoverage(**data)
file_data = ET.Element("file", attrib={
"path": path,
})
coverage_xml.append(file_data)
cover_map = {}
max_line = 1
for c in data.covered:
if isinstance(c, dict):
c = LineCoverage(**c)
if isinstance(c.start, dict):
c.start = RowInfo(**c.start)
if isinstance(c.end, dict):
c.end = RowInfo(**c.end)
max_line = max(max_line, c.end.row)
for i in range(c.start.row, c.end.row+1):
cover_map[i] = 1
for c in data.not_covered:
if isinstance(c, dict):
c = LineCoverage(**c)
if isinstance(c.start, dict):
c.start = RowInfo(**c.start)
if isinstance(c.end, dict):
c.end = RowInfo(**c.end)
max_line = max(max_line, c.end.row)
for i in range(c.start.row, c.end.row+1):
cover_map[i] = 0
for i in range(1, max_line+1):
if cover_map.get(i) is None:
continue
ET.SubElement(file_data, "lineToCover", attrib={
"lineNumber": str(i),
"covered": "true" if (cover_map[i] == 1) else "false",
})
return coverage_xml


if __name__ == "__main__":
# init args
parser = argparse.ArgumentParser(
prog='opa-coverage-to-sonarcloud',
description='Convert opa coverage report to SonarCloud format')
parser.add_argument("input", help='input json file')
parser.add_argument("output", help='output xml file')
args = parser.parse_args()
with open(args.input, encoding="utf-8") as fp:
content = json.load(fp)
overall_coverage = OPACoverage(**content)
sonarcloud_xml = generate_sonarcloud_xml(overall_coverage)

tree = ET.ElementTree(sonarcloud_xml)
ET.indent(tree)
tree.write(args.output, encoding="utf-8", xml_declaration=True)

0 comments on commit b63c03a

Please sign in to comment.