From 2a7b6d0d812c5c987d1b5eab447c1a69c4674ad4 Mon Sep 17 00:00:00 2001 From: OhYee Date: Thu, 18 Dec 2025 19:20:14 +0800 Subject: [PATCH 1/3] ci(workflow): add workflow_call support and improve version bump logic - Add `workflow_call` trigger to allow this workflow to be invoked by other workflows - Default version bump type to `patch` when input is unspecified - Improve robustness of LLM mocking in integration tests by patching module-level imports - Add new CI workflow file, coverage configuration, and coverage checking script - Update dependencies and fix mypy configuration This commit enhances the release testing workflow to support automated triggering from other workflows, ensuring more flexible and reliable CI/CD processes. It also improves test stability by addressing edge cases in LLM mocking and introduces better tooling for code coverage analysis. Change-Id: Iba20089a66ad6e996dfb5930b6d039058d1b1646 Signed-off-by: OhYee --- .github/workflows/ci.yml | 172 +++++ .github/workflows/release-test.yml | 13 +- .gitignore | 4 +- Makefile | 26 + coverage.yaml | 113 +++ pyproject.toml | 3 +- scripts/check_coverage.py | 690 ++++++++++++++++++ .../unittests/integration/test_integration.py | 13 + 8 files changed, 1030 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 coverage.yaml create mode 100644 scripts/check_coverage.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aa734f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,172 @@ +name: CI + +on: + # 仅在 push 时触发测试 + push: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # 获取完整历史用于增量覆盖率检查 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + make setup PYTHON_VERSION=${{ matrix.python-version }} + + - name: Run type check (mypy) + run: | + make mypy-check + + - name: Run unit tests + run: | + make test-unit + + - name: Run coverage + run: | + make coverage + + # 检测文件更改并决定是否构建测试包 + - name: Check for changes in agentrun directory + id: changes + run: | + echo "Checking if agentrun directory has changes..." + # 获取最近两次提交之间的差异 + git diff --name-only HEAD^ HEAD > changed_files.txt || echo "Cannot get diff, checking all files" > changed_files.txt + echo "Changed files:" + cat changed_files.txt || echo "No changed files detected" + + # 检查是否有任何以 agentrun/ 开头的文件 + if grep -q "^agentrun/" changed_files.txt 2>/dev/null; then + echo "agentrun directory has changes" + echo "agentrun_changed=true" >> $GITHUB_OUTPUT + else + echo "agentrun directory has no changes" + echo "agentrun_changed=false" >> $GITHUB_OUTPUT + fi + + # 测试通过后自动构建测试包(仅在 agentrun 目录有变化时触发) + - name: Get latest version from PyPI and calculate next version + id: version + if: steps.changes.outputs.agentrun_changed == 'true' + run: | + # 从 PyPI 获取 agentrun-inner-test 的最新版本 + PYPI_RESPONSE=$(curl -s https://pypi.org/pypi/agentrun-inner-test/json 2>/dev/null || echo "") + + if [ -z "$PYPI_RESPONSE" ] || echo "$PYPI_RESPONSE" | grep -q "Not Found"; then + # 如果包不存在,从 0.0.0 开始 + CURRENT_VERSION="0.0.0" + echo "Package not found on PyPI, starting from 0.0.0" + else + # 从 PyPI 响应中提取最新版本 + CURRENT_VERSION=$(echo "$PYPI_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['info']['version'])") + echo "Latest version on PyPI: $CURRENT_VERSION" + fi + + # 解析版本号 + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + + # 默认为 patch + BUMP_TYPE="patch" + case "$BUMP_TYPE" in + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; + esac + + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + NEW_TAG="agentrun-inner-test-v${NEW_VERSION}" + + echo "VERSION=${NEW_VERSION}" >> $GITHUB_OUTPUT + echo "TAG=${NEW_TAG}" >> $GITHUB_OUTPUT + echo "New version: ${NEW_VERSION}" + echo "New tag: ${NEW_TAG}" + + - name: Update package name and version in pyproject.toml + if: steps.changes.outputs.agentrun_changed == 'true' + run: | + VERSION="${{ steps.version.outputs.VERSION }}" + # 修改包名为 agentrun-inner-test + sed -i 's/name = "agentrun-sdk"/name = "agentrun-inner-test"/' pyproject.toml + # 修改版本号 + sed -i 's/version = "[^"]*"/version = "'${VERSION}'"/' pyproject.toml + echo "Updated pyproject.toml:" + head -10 pyproject.toml + + - name: Update __version__ in __init__.py + if: steps.changes.outputs.agentrun_changed == 'true' + run: | + VERSION="${{ steps.version.outputs.VERSION }}" + if grep -q "__version__" agentrun/__init__.py; then + sed -i 's/__version__ = "[^"]*"/__version__ = "'${VERSION}'"/' agentrun/__init__.py + else + sed -i '1a __version__ = "'${VERSION}'"' agentrun/__init__.py + fi + echo "Updated __init__.py version to ${VERSION}" + grep "__version__" agentrun/__init__.py + + - name: Build package + if: steps.changes.outputs.agentrun_changed == 'true' + run: | + python -m pip install --upgrade pip + pip install build twine + python -m build + echo "Package built successfully" + ls -la dist/ + + - name: Verify package + if: steps.changes.outputs.agentrun_changed == 'true' + run: | + python -m twine check dist/* + echo "Package verification completed" + + - name: Publish to PyPI + if: steps.changes.outputs.agentrun_changed == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + verify-metadata: false + + - name: Create and push tag + if: steps.changes.outputs.agentrun_changed == 'true' + run: | + TAG="${{ steps.version.outputs.TAG }}" + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git tag -a "$TAG" -m "Release test package version ${{ steps.version.outputs.VERSION }}" + git push origin "$TAG" + echo "Created and pushed tag: $TAG" + + - name: Summary + if: steps.changes.outputs.agentrun_changed == 'true' + run: | + echo "## 🎉 Test Package Released!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Package Name:** agentrun-inner-test" >> $GITHUB_STEP_SUMMARY + echo "- **Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY + echo "- **Tag:** ${{ steps.version.outputs.TAG }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commit:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Install with: \`pip install agentrun-inner-test==${{ steps.version.outputs.VERSION }}\`" >> $GITHUB_STEP_SUMMARY + diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml index 15b02e9..ce12cd5 100644 --- a/.github/workflows/release-test.yml +++ b/.github/workflows/release-test.yml @@ -13,6 +13,15 @@ on: - patch # 0.0.1 -> 0.0.2 - minor # 0.0.1 -> 0.1.0 - major # 0.0.1 -> 1.0.0 + + # 支持被其他工作流调用(CI 测试通过后自动触发) + workflow_call: + inputs: + version_bump: + description: '版本递增类型' + required: false + default: 'patch' + type: string jobs: release-test: @@ -49,8 +58,8 @@ jobs: # 解析版本号 IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" - # 根据用户选择递增版本 - BUMP_TYPE="${{ inputs.version_bump }}" + # 根据用户选择递增版本(默认为 patch) + BUMP_TYPE="${{ inputs.version_bump || 'patch' }}" case "$BUMP_TYPE" in major) MAJOR=$((MAJOR + 1)) diff --git a/.gitignore b/.gitignore index 298a78d..eba76a7 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,6 @@ dmypy.json .pytest_cache .env -uv.lock \ No newline at end of file +uv.lock +coverage.json +coverage.json diff --git a/Makefile b/Makefile index 1258965..fdfa26e 100644 --- a/Makefile +++ b/Makefile @@ -122,3 +122,29 @@ install-deps: --dev \ --all-extras \ -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple + +# ============================================================================ +# 测试和覆盖率 +# ============================================================================ + +.PHONY: test +test: ## 运行所有测试 + @uv run pytest tests/ + +.PHONY: test-unit +test-unit: ## 运行单元测试 + @uv run pytest tests/unittests/ + +.PHONY: test-e2e +test-e2e: ## 运行端到端测试 + @uv run pytest tests/e2e/ + +.PHONY: mypy-check +mypy-check: ## 运行 mypy 类型检查 + @uv run mypy --config-file mypy.ini . + +.PHONY: coverage +coverage: ## 运行测试并显示覆盖率报告(全量代码 + 增量代码) + @echo "📊 运行覆盖率测试..." + @uv run python scripts/check_coverage.py + diff --git a/coverage.yaml b/coverage.yaml new file mode 100644 index 0000000..bd8ebd9 --- /dev/null +++ b/coverage.yaml @@ -0,0 +1,113 @@ +# 覆盖率配置文件 +# Coverage Configuration File + +# ============================================================================ +# 全量代码覆盖率要求 +# ============================================================================ +full: + # 分支覆盖率要求 (百分比) + branch_coverage: 0 + # 行覆盖率要求 (百分比) + line_coverage: 0 + +# ============================================================================ +# 增量代码覆盖率要求 (相对于基准分支的变更代码) +# ============================================================================ +incremental: + # 分支覆盖率要求 (百分比) + branch_coverage: 0 + # 行覆盖率要求 (百分比) + line_coverage: 0 + +# ============================================================================ +# 特定目录的覆盖率要求 +# 可以为特定目录设置不同的覆盖率阈值 +# ============================================================================ +directory_overrides: + # 为除 server 外的所有文件夹设置 0% 覆盖率要求 + # 这样可以逐个文件夹增加测试,暂时跳过未测试的文件夹 + agentrun/agent_runtime: + full: + branch_coverage: 0 + line_coverage: 0 + incremental: + branch_coverage: 0 + line_coverage: 0 + + agentrun/credential: + full: + branch_coverage: 0 + line_coverage: 0 + incremental: + branch_coverage: 0 + line_coverage: 0 + + agentrun/integration: + full: + branch_coverage: 0 + line_coverage: 0 + incremental: + branch_coverage: 0 + line_coverage: 0 + + agentrun/model: + full: + branch_coverage: 0 + line_coverage: 0 + incremental: + branch_coverage: 0 + line_coverage: 0 + + agentrun/sandbox: + full: + branch_coverage: 0 + line_coverage: 0 + incremental: + branch_coverage: 0 + line_coverage: 0 + + agentrun/toolset: + full: + branch_coverage: 0 + line_coverage: 0 + incremental: + branch_coverage: 0 + line_coverage: 0 + + agentrun/utils: + full: + branch_coverage: 0 + line_coverage: 0 + incremental: + branch_coverage: 0 + line_coverage: 0 + + # server 模块保持默认的 95% 覆盖率要求 + agentrun/server: + full: + branch_coverage: 0 + line_coverage: 0 + incremental: + branch_coverage: 0 + line_coverage: 0 + +# ============================================================================ +# 排除配置 +# ============================================================================ + +# 排除的目录(不计入覆盖率统计) +exclude_directories: + - "tests/" + - "*__pycache__*" + - "*_async_template.py" + - "codegen/" + - "examples/" + - "build/" + - "*.egg-info" + +# 排除的文件模式 +exclude_patterns: + - "*_test.py" + - "test_*.py" + - "conftest.py" + diff --git a/pyproject.toml b/pyproject.toml index cf7cc65..543806c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ dev = [ "pyyaml>=6.0.3", "fastapi>=0.104.0", "uvicorn>=0.24.0", + "langchain_mcp_adapters>=0.2.1", ] [tool.pyink] @@ -101,7 +102,7 @@ known_third_party = ["alibabacloud_tea_openapi", "alibabacloud_devs20230714", "a sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] [tool.mypy] -python_version = "0.0.8" +python_version = "3.10" exclude = "tests/" plugins = ["pydantic.mypy"] # Start with non-strict mode, and switch to strict mode later. diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py new file mode 100644 index 0000000..292d195 --- /dev/null +++ b/scripts/check_coverage.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python3 +"""覆盖率检查脚本 + +功能: +1. 运行测试并收集覆盖率数据 +2. 计算全量代码和增量代码的覆盖率 +3. 根据配置文件检查覆盖率是否达标 +4. 输出详细的覆盖率报告 + +使用方式: + # 运行覆盖率检查(默认运行全量并尝试计算增量) + python scripts/check_coverage.py + + # 指定基准分支(用于增量计算) + python scripts/check_coverage.py --base-branch main + + # 只检查特定目录 + python scripts/check_coverage.py --source agentrun/server + + # 运行测试但不检查阈值 + python scripts/check_coverage.py --no-check +""" + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +import yaml + + +@dataclass +class CoverageResult: + """覆盖率结果""" + + total_statements: int + covered_statements: int + total_branches: int + covered_branches: int + + @property + def line_coverage(self) -> float: + """行覆盖率百分比""" + if self.total_statements == 0: + return 100.0 + return (self.covered_statements / self.total_statements) * 100 + + @property + def branch_coverage(self) -> float: + """分支覆盖率百分比""" + if self.total_branches == 0: + return 100.0 + return (self.covered_branches / self.total_branches) * 100 + + +@dataclass +class CoverageThreshold: + """覆盖率阈值""" + + branch_coverage: float = 95.0 + line_coverage: float = 95.0 + + +@dataclass +class CoverageConfig: + """覆盖率配置""" + + full: CoverageThreshold = None + incremental: CoverageThreshold = None + directory_overrides: dict[str, dict[str, CoverageThreshold]] = None + exclude_directories: list[str] = None + exclude_patterns: list[str] = None + + def __post_init__(self): + if self.full is None: + self.full = CoverageThreshold() + if self.incremental is None: + self.incremental = CoverageThreshold() + if self.directory_overrides is None: + self.directory_overrides = {} + if self.exclude_directories is None: + self.exclude_directories = [] + if self.exclude_patterns is None: + self.exclude_patterns = [] + + @classmethod + def _parse_threshold( + cls, data: dict[str, Any], default: CoverageThreshold = None + ) -> CoverageThreshold: + """解析覆盖率阈值配置""" + if default is None: + default = CoverageThreshold() + if not data: + return default + return CoverageThreshold( + branch_coverage=data.get( + "branch_coverage", default.branch_coverage + ), + line_coverage=data.get("line_coverage", default.line_coverage), + ) + + @classmethod + def from_yaml(cls, path: Path) -> "CoverageConfig": + """从 YAML 文件加载配置""" + if not path.exists(): + print(f"⚠️ 配置文件 {path} 不存在,使用默认配置") + return cls() + + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + # 解析全量和增量配置 + full = cls._parse_threshold(data.get("full") or {}) + incremental = cls._parse_threshold( + data.get("incremental") or {}, default=full + ) + + # 解析目录覆盖配置 + directory_overrides = {} + for dir_path, dir_config in ( + data.get("directory_overrides") or {} + ).items(): + if dir_config: + directory_overrides[dir_path] = { + "full": cls._parse_threshold( + dir_config.get("full") or {}, default=full + ), + "incremental": cls._parse_threshold( + dir_config.get("incremental") or {}, default=incremental + ), + } + + return cls( + full=full, + incremental=incremental, + directory_overrides=directory_overrides, + exclude_directories=data.get("exclude_directories") or [], + exclude_patterns=data.get("exclude_patterns") or [], + ) + + def get_threshold_for_directory( + self, directory: str, is_incremental: bool = False + ) -> CoverageThreshold: + """获取特定目录的覆盖率阈值 + + Args: + directory: 目录路径 + is_incremental: 是否为增量覆盖率 + + Returns: + CoverageThreshold: 覆盖率阈值 + """ + threshold_key = "incremental" if is_incremental else "full" + default_threshold = self.incremental if is_incremental else self.full + + if directory in self.directory_overrides: + return self.directory_overrides[directory].get( + threshold_key, default_threshold + ) + return default_threshold + + +def run_command( + cmd: list[str], capture_output: bool = True +) -> subprocess.CompletedProcess: + """运行命令""" + print(f"🔧 运行命令: {' '.join(cmd)}") + result = subprocess.run( + cmd, + capture_output=capture_output, + text=True, + cwd=Path(__file__).parent.parent, + ) + return result + + +def get_changed_files(base_branch: str = "main") -> list[str]: + """获取相对于基准分支的变更文件列表""" + # 获取 merge-base + result = run_command(["git", "merge-base", base_branch, "HEAD"]) + if result.returncode != 0: + print(f"⚠️ 无法获取 merge-base: {result.stderr}") + return [] + + merge_base = result.stdout.strip() + + # 获取变更文件 + result = run_command( + ["git", "diff", "--name-only", merge_base, "HEAD", "--", "*.py"] + ) + if result.returncode != 0: + print(f"⚠️ 无法获取变更文件: {result.stderr}") + return [] + + files = [f for f in result.stdout.strip().split("\n") if f] + return files + + +def get_changed_lines( + base_branch: str = "main", +) -> dict[str, set[int]]: + """获取变更的行号 + + Returns: + dict: {文件路径: {行号集合}} + """ + result = run_command(["git", "merge-base", base_branch, "HEAD"]) + if result.returncode != 0: + return {} + + merge_base = result.stdout.strip() + + # 获取 unified diff + result = run_command( + ["git", "diff", "-U0", merge_base, "HEAD", "--", "*.py"] + ) + if result.returncode != 0: + return {} + + changed_lines: dict[str, set[int]] = {} + current_file = None + + for line in result.stdout.split("\n"): + if line.startswith("+++ b/"): + current_file = line[6:] + if current_file not in changed_lines: + changed_lines[current_file] = set() + elif line.startswith("@@") and current_file: + # 解析 @@ -start,count +start,count @@ + parts = line.split(" ") + if len(parts) >= 3: + new_range = parts[2] # +start,count 或 +start + if new_range.startswith("+"): + new_range = new_range[1:] + if "," in new_range: + start, count = map(int, new_range.split(",")) + else: + start = int(new_range) + count = 1 + for i in range(start, start + count): + changed_lines[current_file].add(i) + + return changed_lines + + +def run_coverage( + source: Optional[str] = None, + test_path: str = "tests/", + extra_args: Optional[list[str]] = None, +) -> bool: + """运行覆盖率测试 + + Args: + source: 源代码目录 + test_path: 测试目录 + extra_args: 额外的 pytest 参数 + + Returns: + bool: 测试是否成功 + """ + cmd = [ + "uv", + "run", + "pytest", + test_path, + "--cov-branch", + "--cov-report=json:coverage.json", + "--cov-report=term-missing", + ] + + if source: + cmd.append(f"--cov={source}") + else: + cmd.append("--cov=agentrun") + + if extra_args: + cmd.extend(extra_args) + + result = run_command(cmd, capture_output=False) + return result.returncode == 0 + + +def parse_coverage_json( + json_path: Path = Path("coverage.json"), +) -> dict[str, Any]: + """解析覆盖率 JSON 报告""" + if not json_path.exists(): + print(f"❌ 覆盖率报告 {json_path} 不存在") + return {} + + with open(json_path, "r", encoding="utf-8") as f: + return json.load(f) + + +def calculate_coverage( + coverage_data: dict[str, Any], + files_filter: Optional[list[str]] = None, + lines_filter: Optional[dict[str, set[int]]] = None, +) -> CoverageResult: + """计算覆盖率 + + Args: + coverage_data: 覆盖率 JSON 数据 + files_filter: 只计算这些文件的覆盖率 + lines_filter: 只计算这些行的覆盖率 {文件: {行号集合}} + + Returns: + CoverageResult: 覆盖率结果 + """ + total_statements = 0 + covered_statements = 0 + total_branches = 0 + covered_branches = 0 + + files = coverage_data.get("files", {}) + + for file_path, file_data in files.items(): + # 应用文件过滤 + if files_filter is not None: + if file_path not in files_filter: + continue + + summary = file_data.get("summary", {}) + + if lines_filter is not None and file_path in lines_filter: + # 增量覆盖率:只计算变更行 + changed_lines = lines_filter[file_path] + executed_lines = set(file_data.get("executed_lines", [])) + missing_lines = set(file_data.get("missing_lines", [])) + + # 只统计变更行中的语句 + changed_executed = changed_lines & executed_lines + changed_missing = changed_lines & missing_lines + file_statements = len(changed_executed) + len(changed_missing) + file_covered = len(changed_executed) + + total_statements += file_statements + covered_statements += file_covered + + # 分支覆盖率(简化处理:按比例计算) + if summary.get("num_branches", 0) > 0: + branch_ratio = file_statements / max( + summary.get("num_statements", 1), 1 + ) + total_branches += int( + summary.get("num_branches", 0) * branch_ratio + ) + covered_branches += int( + summary.get("covered_branches", 0) * branch_ratio + ) + else: + # 全量覆盖率 + total_statements += summary.get("num_statements", 0) + covered_statements += summary.get("covered_lines", 0) + total_branches += summary.get("num_branches", 0) + covered_branches += summary.get("covered_branches", 0) + + return CoverageResult( + total_statements=total_statements, + covered_statements=covered_statements, + total_branches=total_branches, + covered_branches=covered_branches, + ) + + +def calculate_directory_coverage( + coverage_data: dict[str, Any], directory: str +) -> CoverageResult: + """计算特定目录的覆盖率""" + files = coverage_data.get("files", {}) + matching_files = [f for f in files.keys() if f.startswith(directory)] + return calculate_coverage(coverage_data, files_filter=matching_files) + + +def print_coverage_report( + full_coverage: CoverageResult, + incremental_coverage: Optional[CoverageResult] = None, + directory_coverages: Optional[dict[str, CoverageResult]] = None, +): + """打印覆盖率报告""" + print("\n" + "=" * 60) + print("📊 覆盖率报告") + print("=" * 60) + + print("\n📈 全量代码覆盖率:") + print(f" 行覆盖率: {full_coverage.line_coverage:.2f}%") + print( + f" ({full_coverage.covered_statements}/{full_coverage.total_statements} 行)" + ) + print(f" 分支覆盖率: {full_coverage.branch_coverage:.2f}%") + print( + f" ({full_coverage.covered_branches}/{full_coverage.total_branches} 分支)" + ) + + print("\n📈 增量代码覆盖率 (相对于 基准分支):") + if incremental_coverage and incremental_coverage.total_statements > 0: + print(f" 行覆盖率: {incremental_coverage.line_coverage:.2f}%") + print( + f" ({incremental_coverage.covered_statements}/{incremental_coverage.total_statements} 行)" + ) + print(f" 分支覆盖率: {incremental_coverage.branch_coverage:.2f}%") + print( + f" ({incremental_coverage.covered_branches}/{incremental_coverage.total_branches} 分支)" + ) + else: + print(" ⚠️ 无增量覆盖数据(未检测到变更或基准分支差异),增量检查已跳过。") + + if directory_coverages: + print("\n📁 目录覆盖率:") + for directory, coverage in directory_coverages.items(): + print(f"\n {directory}:") + print(f" 行覆盖率: {coverage.line_coverage:.2f}%") + print(f" 分支覆盖率: {coverage.branch_coverage:.2f}%") + + print("\n" + "=" * 60) + + +def check_coverage_thresholds( + config: CoverageConfig, + full_coverage: CoverageResult, + incremental_coverage: Optional[CoverageResult] = None, + directory_coverages: Optional[dict[str, CoverageResult]] = None, +) -> tuple[bool, list[str]]: + """检查覆盖率是否达标 + + Returns: + bool: 是否通过检查 + """ + passed = True + failures: list[str] = [] + print("\n🔍 覆盖率检查:") + + # 检查全量覆盖率 + full_threshold = config.full + if full_coverage.branch_coverage < full_threshold.branch_coverage: + msg = ( + f"全量分支覆盖率 {full_coverage.branch_coverage:.2f}% < {full_threshold.branch_coverage}%" + ) + print(f" ❌ {msg}") + failures.append(msg) + passed = False + else: + print( + f" ✅ 全量分支覆盖率 {full_coverage.branch_coverage:.2f}% " + f">= {full_threshold.branch_coverage}%" + ) + + if full_coverage.line_coverage < full_threshold.line_coverage: + msg = ( + f"全量行覆盖率 {full_coverage.line_coverage:.2f}% < {full_threshold.line_coverage}%" + ) + print(f" ❌ {msg}") + failures.append(msg) + passed = False + else: + print( + f" ✅ 全量行覆盖率 {full_coverage.line_coverage:.2f}% " + f">= {full_threshold.line_coverage}%" + ) + + # 检查增量覆盖率(如果有) + if incremental_coverage and incremental_coverage.total_statements > 0: + incr_threshold = config.incremental + if incremental_coverage.branch_coverage < incr_threshold.branch_coverage: + msg = ( + f"增量分支覆盖率 {incremental_coverage.branch_coverage:.2f}% < {incr_threshold.branch_coverage}%" + ) + print(f" ❌ {msg}") + failures.append(msg) + passed = False + else: + print( + f" ✅ 增量分支覆盖率 {incremental_coverage.branch_coverage:.2f}% " + f">= {incr_threshold.branch_coverage}%" + ) + + if incremental_coverage.line_coverage < incr_threshold.line_coverage: + msg = ( + f"增量行覆盖率 {incremental_coverage.line_coverage:.2f}% < {incr_threshold.line_coverage}%" + ) + print(f" ❌ {msg}") + failures.append(msg) + passed = False + else: + print( + f" ✅ 增量行覆盖率 {incremental_coverage.line_coverage:.2f}% " + f">= {incr_threshold.line_coverage}%" + ) + + # 检查目录覆盖率 + if directory_coverages: + for directory, coverage in directory_coverages.items(): + # 全量覆盖率检查 + dir_full_threshold = config.get_threshold_for_directory( + directory, is_incremental=False + ) + + if coverage.branch_coverage < dir_full_threshold.branch_coverage: + msg = ( + f"{directory} 分支覆盖率 {coverage.branch_coverage:.2f}% < {dir_full_threshold.branch_coverage}%" + ) + print(f" ❌ {msg}") + failures.append(msg) + passed = False + else: + print( + f" ✅ {directory} 分支覆盖率 {coverage.branch_coverage:.2f}% " + f">= {dir_full_threshold.branch_coverage}%" + ) + + if coverage.line_coverage < dir_full_threshold.line_coverage: + msg = ( + f"{directory} 行覆盖率 {coverage.line_coverage:.2f}% < {dir_full_threshold.line_coverage}%" + ) + print(f" ❌ {msg}") + failures.append(msg) + passed = False + else: + print( + f" ✅ {directory} 行覆盖率 {coverage.line_coverage:.2f}% " + f">= {dir_full_threshold.line_coverage}%" + ) + + return passed, failures + + +def main(): + parser = argparse.ArgumentParser( + description="覆盖率检查脚本", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "--base-branch", + default="main", + help="增量覆盖率的基准分支(默认: main)", + ) + parser.add_argument( + "--source", + default=None, + help="源代码目录(默认: agentrun)", + ) + parser.add_argument( + "--test-path", + default="tests/unittests/", + help="测试目录(默认: tests/unittests/)", + ) + parser.add_argument( + "--config", + default="coverage.yaml", + help="覆盖率配置文件(默认: coverage.yaml)", + ) + parser.add_argument( + "--no-check", + action="store_true", + help="只运行测试,不检查覆盖率阈值", + ) + parser.add_argument( + "--check-directories", + nargs="*", + help="检查特定目录的覆盖率", + ) + parser.add_argument( + "--json-only", + action="store_true", + help="只输出 JSON 格式的结果", + ) + + args = parser.parse_args() + + # 加载配置 + config_path = Path(args.config) + config = CoverageConfig.from_yaml(config_path) + + # 运行覆盖率测试 + print("🚀 运行覆盖率测试...") + test_path = args.test_path + if test_path == "tests/": # 如果用户未指定 test-path 参数,则使用 unittests + test_path = "tests/unittests/" + + if not run_coverage( + source=args.source, + test_path=test_path, + ): + print("❌ 测试失败") + sys.exit(1) + + # 解析覆盖率数据 + coverage_data = parse_coverage_json() + if not coverage_data: + sys.exit(1) + + # 计算全量覆盖率(包含项目中所有文件) + full_coverage = calculate_coverage(coverage_data) + + # 尝试计算增量覆盖率(与全量测试合并执行一次后再计算) + print("🔎 计算增量覆盖率(与基准分支相比)...") + incremental_coverage = None + changed_lines = get_changed_lines(args.base_branch) + if not changed_lines: + print("⚠️ 未检测到相对于基准分支的变更行;增量覆盖率将被跳过。") + else: + incremental_coverage = calculate_coverage( + coverage_data, + files_filter=list(changed_lines.keys()), + lines_filter=changed_lines, + ) + + # 计算目录覆盖率:优先使用 config 中的 directory_overrides,然后合并 --check-directories 参数 + directory_coverages: dict[str, CoverageResult] = {} + overrides = list(config.directory_overrides.keys()) if config.directory_overrides else [] + for directory in overrides: + directory_coverages[directory] = calculate_directory_coverage(coverage_data, directory) + + if args.check_directories: + for directory in args.check_directories: + directory_coverages[directory] = calculate_directory_coverage(coverage_data, directory) + + # 包含覆盖率数据中存在但未在 YAML 中声明的目录,使用默认阈值进行计算 + files = coverage_data.get("files", {}) + discovered_dirs: set[str] = set() + for f in files.keys(): + if f.startswith("agentrun/"): + parts = f.split("/") + if len(parts) >= 2: + discovered_dirs.add("/".join(parts[:2])) + + for d in sorted(discovered_dirs): + if d not in directory_coverages: + directory_coverages[d] = calculate_directory_coverage(coverage_data, d) + + # 输出报告 + if args.json_only: + result = { + "full_coverage": { + "line_coverage": full_coverage.line_coverage, + "branch_coverage": full_coverage.branch_coverage, + "total_statements": full_coverage.total_statements, + "covered_statements": full_coverage.covered_statements, + "total_branches": full_coverage.total_branches, + "covered_branches": full_coverage.covered_branches, + } + } + if incremental_coverage: + result["incremental_coverage"] = { + "line_coverage": incremental_coverage.line_coverage, + "branch_coverage": incremental_coverage.branch_coverage, + "total_statements": incremental_coverage.total_statements, + "covered_statements": incremental_coverage.covered_statements, + "total_branches": incremental_coverage.total_branches, + "covered_branches": incremental_coverage.covered_branches, + } + if directory_coverages: + result["directory_coverages"] = { + d: { + "line_coverage": c.line_coverage, + "branch_coverage": c.branch_coverage, + } + for d, c in directory_coverages.items() + } + print(json.dumps(result, indent=2)) + else: + print_coverage_report( + full_coverage, incremental_coverage, directory_coverages + ) + + # 检查覆盖率阈值 + if not args.no_check: + passed, failures = check_coverage_thresholds( + config, full_coverage, incremental_coverage, directory_coverages + ) + if not passed: + print("\n❌ 覆盖率检查未通过") + if failures: + print("\n未通过项:") + for f in failures: + print(f" - {f}") + sys.exit(1) + else: + print("\n✅ 覆盖率检查通过") + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/unittests/integration/test_integration.py b/tests/unittests/integration/test_integration.py index 54ace1b..6156b93 100644 --- a/tests/unittests/integration/test_integration.py +++ b/tests/unittests/integration/test_integration.py @@ -68,6 +68,19 @@ async def fake_acompletion(*args, **kwargs): monkeypatch.setattr("litellm.completion", fake_completion) monkeypatch.setattr("litellm.acompletion", fake_acompletion) + # Also patch the module-level imports in google.adk.models.lite_llm + # Google ADK imports acompletion at module level, so we need to patch + # there as well to ensure the mock is used in all contexts + try: + import google.adk.models.lite_llm as lite_llm_module + + monkeypatch.setattr( + lite_llm_module, "acompletion", fake_acompletion + ) + monkeypatch.setattr(lite_llm_module, "completion", fake_completion) + except ImportError: + pass # google.adk not installed, skip patching + def _setup_respx(self): """Setup respx to intercept all httpx requests to mock base URL""" From 3945d0b70b3ab0c0b5f06b631771d6a570ce725c Mon Sep 17 00:00:00 2001 From: OhYee Date: Thu, 18 Dec 2025 19:27:48 +0800 Subject: [PATCH 2/3] Update .github/workflows/ci.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: OhYee --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa734f6..b3b6f72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,8 +43,13 @@ jobs: id: changes run: | echo "Checking if agentrun directory has changes..." - # 获取最近两次提交之间的差异 - git diff --name-only HEAD^ HEAD > changed_files.txt || echo "Cannot get diff, checking all files" > changed_files.txt + # 获取最近两次提交之间的差异;如果没有父提交,则将所有跟踪文件视为已更改 + if git rev-parse HEAD^ >/dev/null 2>&1; then + git diff --name-only HEAD^ HEAD > changed_files.txt + else + echo "No parent commit; treating all tracked files as changed." + git ls-files > changed_files.txt + fi echo "Changed files:" cat changed_files.txt || echo "No changed files detected" From 454f6e6c7011370399118b9b9334e88789e2c3e1 Mon Sep 17 00:00:00 2001 From: OhYee Date: Thu, 18 Dec 2025 19:28:11 +0800 Subject: [PATCH 3/3] Update scripts/check_coverage.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: OhYee --- scripts/check_coverage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 292d195..d3878fe 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -23,7 +23,6 @@ import argparse import json -import os import subprocess import sys from dataclasses import dataclass