diff --git a/.github/workflows/codecov-analytics.yml b/.github/workflows/codecov-analytics.yml index 0c91594a..0afefc60 100644 --- a/.github/workflows/codecov-analytics.yml +++ b/.github/workflows/codecov-analytics.yml @@ -88,3 +88,33 @@ jobs: flags: api,worker,media-core,web,desktop-ts fail_ci_if_error: true verbose: true + + - name: Upload coverage to Codacy + if: ${{ always() }} + env: + CODACY_API_TOKEN: ${{ secrets.CODACY_API_TOKEN }} + CODACY_ORGANIZATION_PROVIDER: gh + CODACY_USERNAME: Prekzursil + CODACY_PROJECT_NAME: ${{ github.event.repository.name }} + run: | + if [ -z "${CODACY_API_TOKEN}" ]; then + echo "Missing CODACY_API_TOKEN" >&2 + exit 1 + fi + + COVERAGE_REPORTS=( + "coverage/python-coverage.xml" + "apps/web/coverage/lcov.info" + "apps/desktop/coverage/lcov.info" + ) + REPORT_ARGS=() + for report in "${COVERAGE_REPORTS[@]}"; do + if [ ! -f "${report}" ]; then + echo "Missing coverage report: ${report}" >&2 + exit 1 + fi + REPORT_ARGS+=(-r "${report}") + done + + bash <(curl -Ls https://coverage.codacy.com/get.sh) report "${REPORT_ARGS[@]}" --partial + bash <(curl -Ls https://coverage.codacy.com/get.sh) final diff --git a/.github/workflows/sonar-zero.yml b/.github/workflows/sonar-zero.yml index 17c86a69..4150c399 100644 --- a/.github/workflows/sonar-zero.yml +++ b/.github/workflows/sonar-zero.yml @@ -65,7 +65,7 @@ jobs: env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - name: Assert Sonar zero-open gate + - name: Assert Sonar zero-open issues and hotspots gate env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: | diff --git a/apps/api/tests/test_scripts_quality_gates.py b/apps/api/tests/test_scripts_quality_gates.py index c6780a87..112f1378 100644 --- a/apps/api/tests/test_scripts_quality_gates.py +++ b/apps/api/tests/test_scripts_quality_gates.py @@ -118,6 +118,7 @@ def test_sonar_evaluate_status_ignores_open_issues_when_flag_set(): findings = module.evaluate_status( open_issues=12, + open_hotspots=0, quality_gate="OK", require_quality_gate=True, ignore_open_issues=True, @@ -131,9 +132,70 @@ def test_sonar_evaluate_status_still_enforces_quality_gate(): findings = module.evaluate_status( open_issues=12, + open_hotspots=0, quality_gate="ERROR", require_quality_gate=True, ignore_open_issues=True, ) _expect(any("quality gate" in item for item in findings), "Expected quality gate finding") + + +def test_sonar_evaluate_status_enforces_open_hotspots(): + module = _load_module("check_sonar_zero") + + findings = module.evaluate_status( + open_issues=0, + open_hotspots=3, + quality_gate="OK", + require_quality_gate=False, + ignore_open_issues=True, + ) + + _expect( + any("security hotspots" in item for item in findings), + "Expected open hotspot finding even when open issues are ignored", + ) + + +def test_sonar_query_status_reads_hotspots(monkeypatch): + module = _load_module("check_sonar_zero") + responses = [ + {"paging": {"total": 4}}, + {"projectStatus": {"status": "OK"}}, + {"paging": {"total": 2}}, + ] + monkeypatch.setattr(module, "_request_json", lambda _url, _auth: responses.pop(0)) + + open_issues, quality_gate, open_hotspots = module._query_sonar_status( + api_base="https://sonarcloud.io", + auth="Basic abc", + project_key="Prekzursil_Reframe", + branch="main", + pull_request="", + ) + + _expect(open_issues == 4, "Expected open issues to be parsed") + _expect(quality_gate == "OK", "Expected quality gate to be parsed") + _expect(open_hotspots == 2, "Expected open hotspots to be parsed") + + +def test_sonar_render_md_includes_open_hotspots(): + module = _load_module("check_sonar_zero") + + rendered = module._render_md( + { + "status": "fail", + "project_key": "Prekzursil_Reframe", + "scope": "branch", + "branch": "main", + "pull_request": None, + "open_issues": 0, + "open_hotspots": 5, + "quality_gate": "OK", + "timestamp_utc": "2026-03-09T00:00:00+00:00", + "findings": ["Sonar reports 5 open security hotspots pending review (expected 0)."], + } + ) + + _expect("- Open hotspots: `5`" in rendered, "Expected hotspot count in markdown output") diff --git a/scripts/quality/check_sonar_zero.py b/scripts/quality/check_sonar_zero.py old mode 100644 new mode 100755 index bdeae035..7779729d --- a/scripts/quality/check_sonar_zero.py +++ b/scripts/quality/check_sonar_zero.py @@ -23,7 +23,9 @@ def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Assert SonarCloud has zero actionable open issues.") + parser = argparse.ArgumentParser( + description="Assert SonarCloud has zero actionable open issues and zero open security hotspots." + ) parser.add_argument("--project-key", required=True, help="Sonar project key") parser.add_argument("--token", default="", help="Sonar token (falls back to SONAR_TOKEN env)") parser.add_argument("--branch", default="", help="Optional branch scope") @@ -76,7 +78,7 @@ def _query_sonar_status( project_key: str, branch: str, pull_request: str, -) -> tuple[int, str]: +) -> tuple[int, str, int]: issues_query = { "componentKeys": project_key, "resolved": "false", @@ -101,12 +103,28 @@ def _query_sonar_status( gate_payload = _request_json(gate_url, auth) project_status = (gate_payload.get("projectStatus") or {}) quality_gate = str(project_status.get("status") or "UNKNOWN") - return open_issues, quality_gate + + hotspot_query = { + "projectKey": project_key, + "status": "TO_REVIEW", + "ps": "1", + } + if branch: + hotspot_query["branch"] = branch + if pull_request: + hotspot_query["pullRequest"] = pull_request + hotspot_url = f"{api_base}/api/hotspots/search?{urllib.parse.urlencode(hotspot_query)}" + hotspot_payload = _request_json(hotspot_url, auth) + hotspot_paging = hotspot_payload.get("paging") or {} + open_hotspots = int(hotspot_paging.get("total") or 0) + + return open_issues, quality_gate, open_hotspots def evaluate_status( *, open_issues: int, + open_hotspots: int, quality_gate: str, require_quality_gate: bool, ignore_open_issues: bool, @@ -114,6 +132,8 @@ def evaluate_status( findings: list[str] = [] if not ignore_open_issues and open_issues != 0: findings.append(f"Sonar reports {open_issues} open issues (expected 0).") + if open_hotspots != 0: + findings.append(f"Sonar reports {open_hotspots} open security hotspots pending review (expected 0).") if require_quality_gate and quality_gate != "OK": findings.append(f"Sonar quality gate status is {quality_gate} (expected OK).") return findings @@ -130,6 +150,7 @@ def _render_md(payload: dict) -> str: f"- Branch: `{payload.get('branch')}`", f"- Pull request: `{payload.get('pull_request')}`", f"- Open issues: `{payload.get('open_issues')}`", + f"- Open hotspots: `{payload.get('open_hotspots')}`", f"- Quality gate: `{payload.get('quality_gate')}`", f"- Timestamp (UTC): `{payload['timestamp_utc']}`", "", @@ -170,6 +191,7 @@ def main() -> int: scope = "branch" findings: list[str] = [] open_issues: int | None = None + open_hotspots: int | None = None quality_gate: str | None = None if not token: @@ -178,7 +200,7 @@ def main() -> int: else: auth = _auth_header(token) try: - open_issues, quality_gate = _query_sonar_status( + open_issues, quality_gate, open_hotspots = _query_sonar_status( api_base=api_base, auth=auth, project_key=args.project_key, @@ -187,11 +209,11 @@ def main() -> int: ) quality_gate = quality_gate or "UNKNOWN" - if args.pull_request and open_issues != 0 and args.wait_seconds > 0: + if args.pull_request and (open_issues != 0 or open_hotspots != 0) and args.wait_seconds > 0: deadline = time.time() + max(0, args.wait_seconds) - while open_issues != 0 and time.time() < deadline: + while (open_issues != 0 or open_hotspots != 0) and time.time() < deadline: time.sleep(10) - open_issues, quality_gate = _query_sonar_status( + open_issues, quality_gate, open_hotspots = _query_sonar_status( api_base=api_base, auth=auth, project_key=args.project_key, @@ -203,6 +225,7 @@ def main() -> int: findings.extend( evaluate_status( open_issues=open_issues, + open_hotspots=open_hotspots, quality_gate=quality_gate, require_quality_gate=args.require_quality_gate, ignore_open_issues=args.ignore_open_issues, @@ -221,6 +244,7 @@ def main() -> int: "branch": args.branch or None, "pull_request": args.pull_request or None, "open_issues": open_issues, + "open_hotspots": open_hotspots, "quality_gate": quality_gate, "ignore_open_issues": bool(args.ignore_open_issues), "timestamp_utc": datetime.now(timezone.utc).isoformat(),