diff --git a/.github/actions/git-https-auth/action.yml b/.github/actions/git-https-auth/action.yml new file mode 100644 index 000000000..5a45bafb5 --- /dev/null +++ b/.github/actions/git-https-auth/action.yml @@ -0,0 +1,11 @@ +name: "Configure git for HTTPS GitHub access" +description: >- + Rewrites git@github.com: URLs to https://github.com/ so a later checkout + or clone authenticates over HTTPS (e.g. with a token) instead of SSH. + +runs: + using: composite + steps: + - name: Configure git to use HTTPS for GitHub + shell: bash + run: git config --global url."https://github.com/".insteadOf "git@github.com:" diff --git a/.github/actions/setup-e2e/action.yml b/.github/actions/setup-e2e/action.yml index 24f7c4561..a1f6a2fcc 100644 --- a/.github/actions/setup-e2e/action.yml +++ b/.github/actions/setup-e2e/action.yml @@ -10,8 +10,7 @@ runs: using: composite steps: - name: Configure git to use HTTPS for GitHub - shell: bash - run: git config --global url."https://github.com/".insteadOf "git@github.com:" + uses: ./.github/actions/git-https-auth - name: Install uv (with cache) and Python uses: astral-sh/setup-uv@v7 diff --git a/.github/scripts/post-core-coverage-digest.py b/.github/scripts/post-core-coverage-digest.py new file mode 100644 index 000000000..01d262743 --- /dev/null +++ b/.github/scripts/post-core-coverage-digest.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Post a compact weekly digest for a Core consumer-coverage artifact.""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path + +LOW_COVERAGE_PERCENT = 80.0 +MAX_MISSED_LINE_RANGES = 8 +MAX_LOW_COVERAGE_FILES = 8 + + +@dataclass(frozen=True) +class FileCoverage: + path: str + found: int + hit: int + functions_found: int + functions_hit: int + missed_lines: tuple[int, ...] + + @property + def percent(self) -> float: + return 100 * self.hit / self.found if self.found else 100.0 + + @property + def missed(self) -> int: + return self.found - self.hit + + +def display_path(path: str) -> str: + marker = "/crates/" + return path[path.index(marker) + 1 :] if marker in path else Path(path).name + + +def format_line_ranges(numbers: tuple[int, ...]) -> str: + ranges: list[str] = [] + start = previous = None + for number in numbers: + if start is None: + start = previous = number + elif number == previous + 1: + previous = number + else: + ranges.append(str(start) if start == previous else f"{start}-{previous}") + start = previous = number + if start is not None: + ranges.append(str(start) if start == previous else f"{start}-{previous}") + shown = ", ".join(ranges[:MAX_MISSED_LINE_RANGES]) + return ( + shown + if len(ranges) <= MAX_MISSED_LINE_RANGES + else f"{shown}, … ({len(ranges) - MAX_MISSED_LINE_RANGES} more ranges)" + ) + + +def coverage_marker(percent: float) -> str: + if percent >= LOW_COVERAGE_PERCENT: + return "🟢" + if percent >= 50: + return "🟠" + return "🔴" + + +def parse_lcov(path: Path) -> list[FileCoverage]: + records: list[FileCoverage] = [] + source: str | None = None + found = hit = functions_found = functions_hit = 0 + missed_lines: list[int] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("SF:"): + source = line[3:] + elif line.startswith("LF:"): + found = int(line[3:]) + elif line.startswith("LH:"): + hit = int(line[3:]) + elif line.startswith("FNF:"): + functions_found = int(line[4:]) + elif line.startswith("FNH:"): + functions_hit = int(line[4:]) + elif line.startswith("DA:"): + line_number, count = line[3:].split(",", maxsplit=1) + if count == "0": + missed_lines.append(int(line_number)) + elif line == "end_of_record" and source is not None: + records.append( + FileCoverage( + display_path(source), + found, + hit, + functions_found, + functions_hit, + tuple(missed_lines), + ) + ) + source = None + found = hit = functions_found = functions_hit = 0 + missed_lines = [] + return records + + +def render_digest( + *, lcov_path: Path, label: str, recipients: str, run_url: str, result: str +) -> str: + header = "## 📊 Weekly Core coverage" + if not lcov_path.is_file(): + return "\n".join( + [ + header, + recipients, + "", + f"⚠️ **Coverage unavailable** · workflow `{result}`", + "", + "No LCOV report was produced. Open the run for the failure details.", + "", + f"[Open run]({run_url})", + ] + ) + + files = parse_lcov(lcov_path) + found = sum(item.found for item in files) + hit = sum(item.hit for item in files) + functions_found = sum(item.functions_found for item in files) + functions_hit = sum(item.functions_hit for item in files) + gaps = sorted( + (item for item in files if item.percent < LOW_COVERAGE_PERCENT), + key=lambda item: (item.percent, -item.found, item.path), + ) + line_percent = 100 * hit / found if found else 0.0 + function_percent = 100 * functions_hit / functions_found if functions_found else 0.0 + healthy_files = len(files) - len(gaps) + lines = [ + header, + "", + recipients, + "", + f"**{label}**", + "", + "### Coverage snapshot", + "", + "| Signal | Result |", + "| --- | --- |", + f"| Lines | {coverage_marker(line_percent)} **{line_percent:.2f}%** · {hit}/{found} covered · {found - hit} missing |", + f"| Functions | {coverage_marker(function_percent)} **{function_percent:.2f}%** · {functions_hit}/{functions_found} covered · {functions_found - functions_hit} missing |", + f"| Files at target | {coverage_marker(100 * healthy_files / len(files) if files else 0)} **{healthy_files}/{len(files)}** at or above {LOW_COVERAGE_PERCENT:.0f}% |", + "", + ] + if gaps: + lines.extend( + [ + "### 🎯 Where to focus", + "", + "| Source file | Coverage gap |", + "| --- | --- |", + ] + ) + lines.extend( + f"| `{item.path}` | {coverage_marker(item.percent)} **{item.percent:.2f}%** · {item.missed} lines missing
Lines `{format_line_ranges(item.missed_lines)}` |" + for item in gaps[:MAX_LOW_COVERAGE_FILES] + ) + if len(gaps) > MAX_LOW_COVERAGE_FILES: + lines.extend( + [ + "", + f"_Plus {len(gaps) - MAX_LOW_COVERAGE_FILES} more low-coverage files in the artifact._", + ] + ) + else: + lines.append("✅ Every measured source file meets the coverage target.") + lines.extend( + ["", f"[View the run and full coverage artifact →]({run_url}#artifacts)"] + ) + return "\n".join(lines) + + +def main() -> None: + digest = render_digest( + lcov_path=Path(os.environ["LCOV_PATH"]), + label=os.environ["REPORT_LABEL"], + recipients=os.environ["RECIPIENTS"], + run_url=os.environ["RUN_URL"], + result=os.environ["WORKFLOW_RESULT"], + ) + subprocess.run( + [ + "gh", + "api", + f"repos/{os.environ['REPO']}/commits/{os.environ['SHA']}/comments", + "--method", + "POST", + "-f", + f"body={digest}", + ], + check=True, + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61a76cf4d..f46402c6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Configure git to use HTTPS for GitHub - run: git config --global url."https://github.com/".insteadOf "git@github.com:" + uses: ./.github/actions/git-https-auth - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -76,7 +76,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Configure git to use HTTPS for GitHub - run: git config --global url."https://github.com/".insteadOf "git@github.com:" + uses: ./.github/actions/git-https-auth - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -124,7 +124,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Configure git to use HTTPS for GitHub - run: git config --global url."https://github.com/".insteadOf "git@github.com:" + uses: ./.github/actions/git-https-auth - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -187,7 +187,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Configure git to use HTTPS for GitHub - run: git config --global url."https://github.com/".insteadOf "git@github.com:" + uses: ./.github/actions/git-https-auth - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -233,7 +233,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Configure git to use HTTPS for GitHub - run: git config --global url."https://github.com/".insteadOf "git@github.com:" + uses: ./.github/actions/git-https-auth - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 diff --git a/.github/workflows/python-core-coverage.yml b/.github/workflows/python-core-coverage.yml new file mode 100644 index 000000000..41c0a8305 --- /dev/null +++ b/.github/workflows/python-core-coverage.yml @@ -0,0 +1,193 @@ +name: Python Core Coverage + +# Coverage floor for band-sdk-core-core computed from THIS repo's real test +# suite -- band-sdk-core owns the instrumented build + gate (see its +# `coverage-python-consumer` just recipe); this workflow only resolves the +# pinned version, checks that tag out as a sibling, and runs it. +# +# No explicit Rust-toolchain setup step: band-sdk-core/rust-toolchain.toml +# is picked up natively by rustup's own directory-walk resolution the first +# time a `cargo`/`rustup` command runs with that checkout as its cwd, so +# there is nothing to duplicate here. + +on: + # Weekly consumer coverage report. This is separate from the PR gate so it + # observes main's current coverage without adding PR notifications. + schedule: + - cron: "33 20 * * 0" # Sunday night: 23:33 Israel summer time, 22:33 winter time + pull_request: + branches: [main] + paths: + - '.github/actions/git-https-auth/**' + - '.github/scripts/post-core-coverage-digest.py' + - '.github/workflows/python-core-coverage.yml' + - 'src/**' + - 'tests/**' + - 'pyproject.toml' + - 'uv.lock' + merge_group: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: python-core-coverage-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + coverage: + # Dependabot cannot read repository Actions secrets, so it cannot check + # out the private Core repository with this workflow's token. + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + # Single source of truth: crates/py/scripts/coverage_python_consumer.py's + # REPORT_DIR, relative to band-sdk-core's checkout root. + REPORT_DIR: target/python-consumer-coverage/report + # Single source of truth for band-sdk-core's git tag naming scheme. + CORE_TAG_PREFIX: band-sdk-core-core-v + ARTIFACT_DIR: python-core-coverage-artifact + steps: + - name: Checkout band-sdk-python + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + path: band-sdk-python + + - name: Configure git to use HTTPS for GitHub + # Path is relative to $GITHUB_WORKSPACE, not this workflow file -- + # band-sdk-python is checked out under path: band-sdk-python above, + # not at the workspace root. + uses: ./band-sdk-python/.github/actions/git-https-auth + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: true + cache-dependency-glob: "band-sdk-python/**/pyproject.toml" + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.12' + + - name: Install band-sdk-python dependencies + working-directory: band-sdk-python + run: uv sync --all-packages --locked --extra dev + + - name: Resolve pinned band-sdk-core version + id: pin + working-directory: band-sdk-python + run: | + version=$(uv run python -c "import importlib.metadata; print(importlib.metadata.version('band-sdk-core'))") + + # Pass the version through the environment rather than embedding it + # in the Python source string. + VERSION="$version" uv run python -c " + import os + import sys + from packaging.version import Version + + version = os.environ['VERSION'] + prefix = os.environ['CORE_TAG_PREFIX'] + if Version(version).is_prerelease: + sys.exit( + f'band-sdk-core=={version} is a pre-release: {prefix}{version} ' + 'is never tagged (dev pre-releases publish to PyPI but are never committed ' + 'or tagged in git), so this gate cannot check it out. Pin a stable release ' + 'to run this gate.' + ) + " + + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Checkout band-sdk-core at the pinned version + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: band-ai/band-sdk-core + ref: ${{ env.CORE_TAG_PREFIX }}${{ steps.pin.outputs.version }} + path: band-sdk-core + token: ${{ secrets.CORE_SDK_READ_KEY }} + + - name: Install just and cargo-llvm-cov + uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 # v2 + with: + tool: just,cargo-llvm-cov + + - name: Run band-sdk-python's tests against instrumented band-sdk-core-core + working-directory: band-sdk-core + run: just coverage-python-consumer ../band-sdk-python + + - name: Write coverage summary + if: always() + # No working-directory: band-sdk-core/ may not exist yet (the + # pin step's pre-release guard, or any earlier failure, can skip + # its checkout) -- a nonexistent step cwd fails the step outright + # before this script runs, before the else branch below ever gets + # a chance to report that gracefully. + run: | + summary="band-sdk-core/$REPORT_DIR/summary.txt" + if [ -f "$summary" ]; then + { + echo "## band-sdk-core-core coverage from band-sdk-python's tests" + echo '```' + cat "$summary" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + else + echo "No coverage summary produced -- see the coverage step's log above." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Stage coverage report + if: always() + run: | + report="band-sdk-core/$REPORT_DIR" + mkdir -p "$ARTIFACT_DIR" + [ ! -f "$report/python-consumer.lcov" ] || cp "$report/python-consumer.lcov" "$ARTIFACT_DIR/" + [ ! -f "$report/summary.txt" ] || cp "$report/summary.txt" "$ARTIFACT_DIR/" + [ ! -d "$report/html" ] || cp -R "$report/html" "$ARTIFACT_DIR/" + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: python-core-coverage + path: ${{ env.ARTIFACT_DIR }} + if-no-files-found: warn + + report-weekly: + name: report weekly coverage + needs: coverage + if: "!cancelled() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + WORKFLOW_RESULT: ${{ needs.coverage.result }} + REPORT_LABEL: band-sdk-core Python bindings + LCOV_PATH: artifacts/python-consumer.lcov + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Download coverage report + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-core-coverage + path: artifacts + + - name: Read integrations mentions list + id: mentions + if: github.event_name == 'schedule' + run: .github/scripts/read-integrations-mentions.sh + + - name: Post the weekly coverage digest + env: + RECIPIENTS: ${{ github.event_name == 'schedule' && steps.mentions.outputs.mentions || format('@{0}', github.triggering_actor) }} + run: python .github/scripts/post-core-coverage-digest.py diff --git a/pyproject.toml b/pyproject.toml index 8d360d50e..ffe54d6cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ dependencies = [ "band-client-rest==0.0.27", - "band-sdk-core==2.2.0", + "band-sdk-core==2.5.0", "phoenix-channels-python-client>=0.2.4", "python-dotenv>=1.2.2", "pydantic>=2.0", @@ -189,6 +189,7 @@ dev = [ "pytest-rerunfailures>=14.0.0", "tenacity>=8.0.0", "pytest-timeout>=2.4.0", + "packaging>=23.0", # PEP 440 pre-release detection for the CI coverage-gate version guard "band-testing-python==0.1.4", "httpx>=0.24.0", # Already in main deps, for mocking "pytest-httpx>=0.35.0", # httpx_mock fixture: REST header-emission proof diff --git a/tests/test_python_core_coverage_workflow.py b/tests/test_python_core_coverage_workflow.py new file mode 100644 index 000000000..f600df5e8 --- /dev/null +++ b/tests/test_python_core_coverage_workflow.py @@ -0,0 +1,265 @@ +"""Structural contracts and guard-script behavior for python-core-coverage.yml.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from typing import Any + +import yaml + +from tests.paths import REPO_ROOT + +WORKFLOW_PATH = REPO_ROOT / ".github/workflows/python-core-coverage.yml" + + +def load_workflow() -> dict[str, Any]: + return yaml.load(WORKFLOW_PATH.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + + +def _step(workflow: dict[str, Any], name: str) -> dict[str, Any]: + steps = workflow["jobs"]["coverage"]["steps"] + return next(step for step in steps if step.get("name") == name) + + +def _pin_guard_script(run_text: str) -> str: + """Isolate the pre-release guard invocation from the pin step's run text.""" + lines = run_text.splitlines() + start = next(i for i, line in enumerate(lines) if line.startswith("VERSION=")) + end = next(i for i, line in enumerate(lines) if 'echo "version=$version"' in line) + return "\n".join(lines[start:end]) + + +def _run_pin_guard(version: str) -> subprocess.CompletedProcess[str]: + """Execute the real guard script from the workflow against a given version string.""" + workflow = load_workflow() + run_text = _step(workflow, "Resolve pinned band-sdk-core version")["run"] + core_tag_prefix = workflow["jobs"]["coverage"]["env"]["CORE_TAG_PREFIX"] + script = f'version="{version}"\n{_pin_guard_script(run_text)}' + bash = "bash" + if sys.platform == "win32": + bash = str(Path(os.environ["ProgramFiles"]) / "Git" / "bin" / "bash.exe") + + return subprocess.run( + [bash, "--noprofile", "--norc", "-eo", "pipefail", "-c", script], + capture_output=True, + text=True, + env={**os.environ, "CORE_TAG_PREFIX": core_tag_prefix}, + ) + + +def test_pin_step_resolves_version_into_github_output() -> None: + step = _step(load_workflow(), "Resolve pinned band-sdk-core version") + assert step["id"] == "pin" + assert "importlib.metadata.version('band-sdk-core')" in step["run"] + assert '>> "$GITHUB_OUTPUT"' in step["run"] + assert "version=$version" in step["run"] + + +def test_pin_guard_passes_for_a_stable_version() -> None: + result = _run_pin_guard("2.4.0") + assert result.returncode == 0, result.stderr + + +def test_pin_guard_rejects_dev_prerelease_versions() -> None: + result = _run_pin_guard("2.4.0.dev3") + assert result.returncode == 1 + assert "band-sdk-core==2.4.0.dev3 is a pre-release" in result.stderr + assert "band-sdk-core-core-v2.4.0.dev3" in result.stderr + + +def test_pin_guard_rejects_cargo_style_dev_versions() -> None: + result = _run_pin_guard("2.4.0-dev.3") + assert result.returncode == 1 + assert "band-sdk-core==2.4.0-dev.3 is a pre-release" in result.stderr + + +def test_checkout_ref_matches_pin_step_output() -> None: + step = _step(load_workflow(), "Checkout band-sdk-core at the pinned version") + assert ( + step["with"]["ref"] + == "${{ env.CORE_TAG_PREFIX }}${{ steps.pin.outputs.version }}" + ) + + +def test_core_checkout_uses_the_scoped_read_secret() -> None: + workflow = load_workflow() + steps = workflow["jobs"]["coverage"]["steps"] + checkout = _step(workflow, "Checkout band-sdk-core at the pinned version") + names = [step.get("name") for step in steps] + + assert checkout["with"]["token"] == "${{ secrets.CORE_SDK_READ_KEY }}" + assert "Generate GitHub App Token (scoped to band-sdk-core)" not in names + + +def test_coverage_job_skips_dependabot_without_the_read_secret() -> None: + coverage = load_workflow()["jobs"]["coverage"] + assert coverage["if"] == "github.actor != 'dependabot[bot]'" + + +def test_prerelease_guard_runs_before_the_cross_repo_checkout() -> None: + steps = load_workflow()["jobs"]["coverage"]["steps"] + names = [step.get("name") for step in steps] + assert names.index("Resolve pinned band-sdk-core version") < names.index( + "Checkout band-sdk-core at the pinned version" + ) + + +def test_core_tag_prefix_is_a_single_source_of_truth() -> None: + workflow = load_workflow() + prefix = workflow["jobs"]["coverage"]["env"]["CORE_TAG_PREFIX"] + assert prefix == "band-sdk-core-core-v" + checkout_ref = _step(workflow, "Checkout band-sdk-core at the pinned version")[ + "with" + ]["ref"] + pin_run = _step(workflow, "Resolve pinned band-sdk-core version")["run"] + assert "${{ env.CORE_TAG_PREFIX }}" in checkout_ref + assert "os.environ['CORE_TAG_PREFIX']" in pin_run + assert "band-sdk-core-core-v" not in checkout_ref + assert "band-sdk-core-core-v" not in pin_run + + +def test_report_dir_is_a_single_source_of_truth() -> None: + workflow = load_workflow() + report_dir = workflow["jobs"]["coverage"]["env"]["REPORT_DIR"] + summary_run = _step(workflow, "Write coverage summary")["run"] + stage_run = _step(workflow, "Stage coverage report")["run"] + upload_paths = _step(workflow, "Upload coverage report")["with"]["path"] + assert "$REPORT_DIR" in summary_run + assert "$REPORT_DIR" in stage_run + assert "$ARTIFACT_DIR" in stage_run + assert upload_paths == "${{ env.ARTIFACT_DIR }}" + assert report_dir not in summary_run + assert report_dir not in stage_run + + +def test_coverage_artifact_has_a_single_staging_root() -> None: + coverage = load_workflow()["jobs"]["coverage"] + assert coverage["env"]["ARTIFACT_DIR"] == "python-core-coverage-artifact" + + +def test_write_coverage_summary_has_no_working_directory() -> None: + # band-sdk-core/ may not exist (the pin step's guard, or any earlier + # failure, can skip its checkout) -- a nonexistent step cwd fails the + # step outright before its own else-branch fallback can run. + step = _step(load_workflow(), "Write coverage summary") + assert "working-directory" not in step + assert step["run"].strip().startswith('summary="band-sdk-core/') + + +def test_write_coverage_summary_falls_back_when_missing() -> None: + run_text = _step(load_workflow(), "Write coverage summary")["run"] + assert "No coverage summary produced" in run_text + assert '>> "$GITHUB_STEP_SUMMARY"' in run_text + + +def test_download_artifact_uses_only_supported_inputs() -> None: + report_steps = load_workflow()["jobs"]["report-weekly"]["steps"] + step = next( + step for step in report_steps if step.get("name") == "Download coverage report" + ) + assert "if-no-files-found" not in step["with"] + + +def test_weekly_report_is_scheduled_and_mentions_the_integrations_roster() -> None: + workflow = load_workflow() + assert workflow["on"]["schedule"] == [{"cron": "33 20 * * 0"}] + + report = workflow["jobs"]["report-weekly"] + assert ( + report["if"] + == "!cancelled() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" + ) + assert report["permissions"] == {"contents": "write"} + report_steps = report["steps"] + mention_step = next( + step + for step in report_steps + if step["name"] == "Read integrations mentions list" + ) + digest_step = next( + step + for step in report_steps + if step["name"] == "Post the weekly coverage digest" + ) + assert mention_step["id"] == "mentions" + assert mention_step["if"] == "github.event_name == 'schedule'" + assert ( + digest_step["env"]["RECIPIENTS"] + == "${{ github.event_name == 'schedule' && steps.mentions.outputs.mentions || format('@{0}', github.triggering_actor) }}" + ) + + +def test_weekly_digest_identifies_low_and_completely_uncovered_files( + tmp_path: Path, +) -> None: + script_path = REPO_ROOT / ".github/scripts/post-core-coverage-digest.py" + spec = spec_from_file_location("post_core_coverage_digest", script_path) + assert spec is not None and spec.loader is not None + module = module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + + lcov = tmp_path / "coverage.lcov" + lcov.write_text( + "\n".join( + [ + "SF:/work/crates/core/src/covered.rs", + "FNF:2", + "FNH:2", + "DA:1,1", + "DA:2,1", + "LF:10", + "LH:10", + "end_of_record", + "SF:/work/crates/core/src/low.rs", + "FNF:2", + "FNH:1", + "DA:10,1", + "DA:11,1", + "DA:12,0", + "DA:13,0", + "LF:10", + "LH:2", + "end_of_record", + "SF:/work/crates/core/src/none.rs", + "FNF:1", + "FNH:0", + "DA:20,0", + "DA:21,0", + "DA:22,0", + "DA:23,0", + "LF:4", + "LH:0", + "end_of_record", + ] + ) + ) + + digest = module.render_digest( + lcov_path=lcov, + label="Core", + recipients="@bandzalkin", + run_url="https://example.test/run", + result="success", + ) + + assert "📊 Weekly Core coverage" in digest + assert "| Lines | 🟠 **50.00%** · 12/24 covered · 12 missing |" in digest + assert "| Functions | 🟠 **60.00%** · 3/5 covered · 2 missing |" in digest + assert "| Files at target | 🔴 **1/3** at or above 80% |" in digest + assert ( + "`crates/core/src/none.rs` | 🔴 **0.00%** · 4 lines missing
Lines `20-23`" + in digest + ) + assert ( + "`crates/core/src/low.rs` | 🔴 **20.00%** · 8 lines missing
Lines `12-13`" + in digest + ) + assert "covered.rs" not in digest + assert module.format_line_ranges((1, 3, 5, 7, 9, 11, 13, 15, 17)) == ( + "1, 3, 5, 7, 9, 11, 13, 15, … (1 more ranges)" + ) diff --git a/uv.lock b/uv.lock index ab9022a0a..ca8d19ef4 100644 --- a/uv.lock +++ b/uv.lock @@ -643,6 +643,7 @@ dev = [ { name = "opentelemetry-instrumentation-logging" }, { name = "opentelemetry-resourcedetector-gcp" }, { name = "opentelemetry-sdk", version = "1.44.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, { name = "pre-commit" }, { name = "pydantic-ai-slim" }, { name = "pydantic-settings", version = "2.14.2", source = { registry = "https://pypi.org/simple" } }, @@ -780,7 +781,7 @@ requires-dist = [ { name = "anthropic", marker = "extra == 'dev-parlant'", specifier = ">=0.75.0,<1" }, { name = "async-lru", specifier = ">=2.3.0" }, { name = "band-client-rest", specifier = "==0.0.27" }, - { name = "band-sdk-core", specifier = "==2.2.0" }, + { name = "band-sdk-core", specifier = "==2.5.0" }, { name = "band-testing-python", marker = "extra == 'dev'", specifier = "==0.1.4" }, { name = "band-testing-python", marker = "extra == 'dev-crewai'", specifier = "==0.1.4" }, { name = "band-testing-python", marker = "extra == 'dev-parlant'", specifier = "==0.1.4" }, @@ -847,6 +848,7 @@ requires-dist = [ { name = "opentelemetry-resourcedetector-gcp", marker = "extra == 'dev'", specifier = ">=1.12.0a0,!=1.13.0" }, { name = "opentelemetry-resourcedetector-gcp", marker = "extra == 'google-adk'", specifier = ">=1.12.0a0,!=1.13.0" }, { name = "opentelemetry-sdk", marker = "extra == 'dev'", specifier = ">=1.44.0" }, + { name = "packaging", marker = "extra == 'dev'", specifier = ">=23.0" }, { name = "parlant", marker = "extra == 'dev-parlant'", specifier = ">=3.3.2" }, { name = "parlant", marker = "extra == 'parlant'", specifier = ">=3.3.2" }, { name = "phoenix-channels-python-client", specifier = ">=0.2.4" }, @@ -925,17 +927,17 @@ provides-extras = ["logging", "desktop", "codex", "opencode", "letta", "pydantic [[package]] name = "band-sdk-core" -version = "2.2.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/b6/6432cd80745c68517ffb2efb9f8dc5f5d2ad423eaccb002d7a14dd1d28f9/band_sdk_core-2.2.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2eaa8f62a5b806d10993ea4feb73f1c48d3c959b9539bfbcdbd1ffa1f426b998", size = 475871, upload-time = "2026-09-01T11:30:26.358Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1d/a20cbfce00fd32ec5e1145528138fd2b0d8af12dd09c337a59c6c4b79f69/band_sdk_core-2.2.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:52c4c6af089e81d877e34b839694e8b01547d0f25891cf1bc9c717cf32278f59", size = 477493, upload-time = "2026-09-01T11:30:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d8/4ec2c9a0b37027072e92dbe2912fce0d593ee88c3f96e1281882429588ff/band_sdk_core-2.2.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c99fad0f3edb0fc9a9d42079a2be76d573c4dd7a9579ce4b5aea1d5fabaaf9e", size = 526652, upload-time = "2026-09-01T11:30:29.398Z" }, - { url = "https://files.pythonhosted.org/packages/61/1a/548e4f355517ee7cd4422906055765b955ec543efc95ef4993ac8a2a84ed/band_sdk_core-2.2.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:431e7185d2a8659371a6fb22aa0a3383e935607d1c8944f488eebf70b0821cf7", size = 528152, upload-time = "2026-09-01T11:30:30.953Z" }, - { url = "https://files.pythonhosted.org/packages/37/9a/6b19bf76fc55d4f5a1315ac036506aea71c499651b0fbe328004c9e40011/band_sdk_core-2.2.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1996fb61fb22d5750df214b3d180e183cdbf3f7e36cea960ca8ca2442a93b888", size = 705290, upload-time = "2026-09-01T11:30:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8a/5bceb2a5732fb51b1fa62d6dea172104ea79080c768a0aa4a4d42ddf5d5c/band_sdk_core-2.2.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8688f4c55c7cd778e5bb2846b6ada03e7c392d9eb8565a698fdb3df6e29faa8b", size = 743509, upload-time = "2026-09-01T11:30:34.036Z" }, - { url = "https://files.pythonhosted.org/packages/0e/00/c267b0fc208f121ad25b41f41a18c5d77c9280049eae8a359821d37c2a33/band_sdk_core-2.2.0-cp311-abi3-win_amd64.whl", hash = "sha256:704bff82b7494f1997df1aa20def09d7431075f0ad0a1dd4895a4b916409a9f2", size = 348322, upload-time = "2026-09-01T11:30:35.664Z" }, - { url = "https://files.pythonhosted.org/packages/79/d5/46a9dce9b20509904d69b181685fd2d1d498c657466dcd58ba0fedb78248/band_sdk_core-2.2.0-cp311-abi3-win_arm64.whl", hash = "sha256:7f49e75f6f890f38ba7366fb9264c8ca01c34f231db7ab8f08f4c6cc9c161295", size = 331334, upload-time = "2026-09-01T11:30:36.826Z" }, + { url = "https://files.pythonhosted.org/packages/41/e9/086ccc58b8355d06910803753a1b82a0cbaa85a4b1e9159aced764712474/band_sdk_core-2.5.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a7075054abfe11142f3142649f10a529bbbbd075d8f0f0c6b7b39f56cd421807", size = 478010, upload-time = "2026-09-15T12:24:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/65/4f/72a6e4a50803d7f46f04f9031d5391133dd4d7bd503725b793da76e6b112/band_sdk_core-2.5.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:f860bb80fb16b45c90e60bab8867840e14cb832f965289279b591ade1404e29a", size = 479901, upload-time = "2026-09-15T12:24:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/6f/aa/f7e29c6845f04f7ebe8ee8753689f5a3031a54754372f6d37f6f5a5df615/band_sdk_core-2.5.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:581aa0be042625ea30be3854da472865c056604a99f901d46c881d65295a8ac4", size = 531534, upload-time = "2026-09-15T12:24:23.301Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ae/0dc4072394cbc5d5577e28f13c2fa7ba25747e00ccc3715a518f490168b8/band_sdk_core-2.5.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6c323bde9df4410eec81129fb3cb18fd62fc3d77bc1532863e1b1078afcf3038", size = 530187, upload-time = "2026-09-15T12:24:25.012Z" }, + { url = "https://files.pythonhosted.org/packages/91/84/0b3f3b1d4f3439dec091e984b0b8a479476ad9211f29b9a978da3f5f7723/band_sdk_core-2.5.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54241dc1d3b78e606e2299dbcdf526c5fe54a7600fde919815e1879e813a1380", size = 709933, upload-time = "2026-09-15T12:24:26.762Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/52bb27eabbc036a48e84a59a2c943556d894bc38281606a390b5340abbbb/band_sdk_core-2.5.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:60d684e4a12bec4fad30eca33249c3b6222e9a6a65dc582a4ac5e145605975e9", size = 746454, upload-time = "2026-09-15T12:24:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/eb/14/dbf8075e7a8902523d2c4736dc16f305d3e778e310e87417ec0a3f328fe2/band_sdk_core-2.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:ed4e4a15b0eab970dee152a39388852f6ba25567de4d783abbcd082cc4964e45", size = 351353, upload-time = "2026-09-15T12:24:29.617Z" }, + { url = "https://files.pythonhosted.org/packages/8d/88/1127d1917a8f0cacbef8f3d510f17f6b71ecfc25ed2f1f16982c48f3cde4/band_sdk_core-2.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:63ff44ff7514e81f951b6b164db484a992006d6915fcc2624e46ffdfcbe12cee", size = 333829, upload-time = "2026-09-15T12:24:31.273Z" }, ] [[package]]