diff --git a/CITATION.cff b/CITATION.cff index ce993df..67bde6e 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -29,5 +29,5 @@ keywords: - bot - software license: MIT -version: 0.5.3 -date-released: 2026-07-06 \ No newline at end of file +version: 0.5.4.dev1 +date-released: 2026-07-08 \ No newline at end of file diff --git a/README.md b/README.md index 9a0915e..eed43f7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19468976.svg)](https://doi.org/10.5281/zenodo.19468976) [![SWH](https://archive.softwareheritage.org/badge/swh:1:dir:9f3d474c040158cc675e8db135767e0d2d3fba7b/)](https://archive.softwareheritage.org/swh:1:dir:9f3d474c040158cc675e8db135767e0d2d3fba7b;origin=https://github.com/SoftwareUnderstanding/sw-metadata-bot;visit=swh:1:snp:03da984544982fdf5aa1f556012b917250f1e4c3;anchor=swh:1:rev:fc64150edf2e35f045cf3858d81b00ca6e7f6e68) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-0.5.3-blue.svg)](https://github.com/SoftwareUnderstanding/sw-metadata-bot/releases) +[![Version](https://img.shields.io/badge/version-0.5.4.dev1-blue.svg)](https://github.com/SoftwareUnderstanding/sw-metadata-bot/releases) [![Python 3.11--3.12](https://img.shields.io/badge/python-3.11--3.12-blue.svg)](https://www.python.org/downloads/) [![CI (build)](https://github.com/SoftwareUnderstanding/sw-metadata-bot/actions/workflows/ci.yml/badge.svg)](https://github.com/SoftwareUnderstanding/sw-metadata-bot/actions/workflows/ci.yml) [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff) diff --git a/codemeta.json b/codemeta.json index aa4929a..a8eeded 100644 --- a/codemeta.json +++ b/codemeta.json @@ -56,7 +56,7 @@ ], "codeRepository": "https://github.com/SoftwareUnderstanding/sw-metadata-bot", "dateCreated": "2025-12-15", - "dateModified": "2026-07-06", + "dateModified": "2026-07-08", "datePublished": "2026-01-16", "description": "A repository to keep the code of the RSMetaCheck bot for pushing issues with existing repository metadata", "downloadUrl": "https://github.com/SoftwareUnderstanding/sw-metadata-bot/releases", @@ -83,8 +83,8 @@ "python-dotenv>=1.0.0", "requests>=2.32.5" ], - "version": "0.5.3", - "softwareVersion": "0.5.3", + "version": "0.5.4.dev1", + "softwareVersion": "0.5.4.dev1", "maintainer": { "@id": "https://orcid.org/0000-0001-5226-3089", "givenName": "Tom", diff --git a/pyproject.toml b/pyproject.toml index 621c2ab..1558c42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sw-metadata-bot" -version = "0.5.3" +version = "0.5.4.dev1" description = "Metadata quality bot for software repositories, leveraging metacheck for analysis and GitHub/GitLab APIs for issue management." readme = "README.md" requires-python = ">=3.11,<3.13" diff --git a/src/sw_metadata_bot/constants.py b/src/sw_metadata_bot/constants.py index 5b57ec4..2f8a575 100644 --- a/src/sw_metadata_bot/constants.py +++ b/src/sw_metadata_bot/constants.py @@ -134,6 +134,8 @@ FILENAME_CONFIG_SNAPSHOT, } ) + +FILENAME_REPORT_SUMMARY = "report_summary.json" """Files to exclude when cleaning up metacheck output directories.""" diff --git a/src/sw_metadata_bot/main.py b/src/sw_metadata_bot/main.py index 0d68cff..12d1242 100644 --- a/src/sw_metadata_bot/main.py +++ b/src/sw_metadata_bot/main.py @@ -4,6 +4,7 @@ from .pipeline import run_analysis_command from .publish import publish_command, simulate_publish_command +from .report_summary import summarize_report_command from .verify_tokens import verify_tokens_command @@ -22,6 +23,7 @@ def cli(): cli.add_command(run_analysis_command, name="run-analysis") cli.add_command(publish_command, name="publish") cli.add_command(simulate_publish_command, name="simulate-publish") +cli.add_command(summarize_report_command, name="report-summary") def main(): diff --git a/src/sw_metadata_bot/report_summary.py b/src/sw_metadata_bot/report_summary.py new file mode 100644 index 0000000..ac187bd --- /dev/null +++ b/src/sw_metadata_bot/report_summary.py @@ -0,0 +1,131 @@ +"""Create a summary from existing report for dashboard""" + +import json +from collections import Counter +from pathlib import Path + +import click + +from . import constants +from .reporting import RunReport, load_report + + +@click.command() +@click.option( + "--analysis-root", + type=click.Path(exists=True, dir_okay=True, path_type=Path), + default=None, + help="Previous run_report.json used for incremental issue handling.", +) +def summarize_report_command(analysis_root: Path): + """Summarize report.json file and save the summary file in the analysis root folder.""" + summary = summarize_analysis_folder(analysis_root) + + save_summary_report(analysis_root, summary) + + +def summarize_report(report: RunReport) -> dict[str, object]: + """Build a compact summary for plotting and comparison.""" + repository_count = len(report.records) + toolmetadata = report.get_tool_metadata() + + pitfalls_by_id: Counter[str] = Counter() + warnings_by_id: Counter[str] = Counter() + total_pitfalls = 0 + total_warnings = 0 + issues_created = 0 + actions: Counter[str] = Counter() + reason_codes: Counter[str] = Counter() + reason_codes_by_action: dict[str, Counter[str]] = {} + + for record in report.records: + for pitfall_id in record.pitfalls_ids: + pitfalls_by_id[pitfall_id] += 1 + for warning_id in record.warnings_ids: + warnings_by_id[warning_id] += 1 + + total_pitfalls += int(record.pitfalls_count or 0) + total_warnings += int(record.warnings_count or 0) + + if record.action == constants.ACTION_CREATED: + issues_created += 1 + + if record.action is not None: + actions[record.action] += 1 + reason_codes_by_action.setdefault(record.action, Counter()) + + if record.reason_code is not None: + reason_codes[record.reason_code] += 1 + if record.action is not None: + reason_codes_by_action[record.action][record.reason_code] += 1 + + reason_code_percentages_by_action: dict[str, dict[str, float]] = {} + for action_name, counter in reason_codes_by_action.items(): + total_for_action = sum(counter.values()) + if total_for_action == 0: + reason_code_percentages_by_action[action_name] = {} + continue + reason_code_percentages_by_action[action_name] = { + reason_code: (count / total_for_action) * 100.0 + for reason_code, count in sorted(counter.items()) + } + + return { + "tool_metadata": toolmetadata.to_dict(), + "run_metadata": report.run_metadata, + "repository_count": repository_count, + "pitfalls_by_id": dict(sorted(pitfalls_by_id.items())), + "warnings_by_id": dict(sorted(warnings_by_id.items())), + "total_pitfalls": total_pitfalls, + "total_warnings": total_warnings, + "issues_created": issues_created, + "actions": dict(sorted(actions.items())), + "reason_codes": dict(sorted(reason_codes.items())), + "reason_codes_by_action": { + action_name: dict(sorted(counter.items())) + for action_name, counter in sorted(reason_codes_by_action.items()) + }, + "reason_code_percentages_by_action": { + action_name: dict(sorted(percentages.items())) + for action_name, percentages in sorted( + reason_code_percentages_by_action.items() + ) + }, + "pitfalls_per_repository": ( + total_pitfalls / repository_count if repository_count else 0.0 + ), + "warnings_per_repository": ( + total_warnings / repository_count if repository_count else 0.0 + ), + "issues_created_per_repository": ( + issues_created / repository_count if repository_count else 0.0 + ), + } + + +def summarize_analysis_folder(analysis_folder: Path | str) -> dict[str, object]: + """Load run_report.json from an analysis directory and summarize it.""" + analysis_path = Path(analysis_folder) + report_path = analysis_path / constants.FILENAME_RUN_REPORT + if analysis_path.is_file(): + report_path = analysis_path + + return summarize_report(load_report(report_path)) + + +def save_summary_report(analysis_folder: Path, summary_data: dict): + """Save summary of the analysis""" + if summary_data: + summary_path = analysis_folder / constants.FILENAME_REPORT_SUMMARY + with open(summary_path, "w", encoding="utf-8") as f: + json.dump(summary_data, f, indent=2) + else: + print("Error: No summary info") + + +if __name__ == "__main__": + analysis_root = Path("./outputs/ossr/20260702/") + + summary = summarize_analysis_folder(analysis_root) + + save_summary_report(analysis_root, summary) diff --git a/src/sw_metadata_bot/reporting.py b/src/sw_metadata_bot/reporting.py index 4d22e0a..187ee88 100644 --- a/src/sw_metadata_bot/reporting.py +++ b/src/sw_metadata_bot/reporting.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path +from typing import Any, Mapping from . import constants @@ -43,6 +44,148 @@ class RecordLifecycle: error: str | None = None +@dataclass(frozen=True) +class ReportRecord: + """Normalized representation of a single repository report record.""" + + repo_url: str | None = None + platform: str | None = None + pitfalls_count: int | None = None + warnings_count: int | None = None + issue_url: str | None = None + analysis_date: str | None = None + sw_metadata_bot_version: str | None = None + rsmetacheck_version: str | None = None + pitfalls_ids: tuple[str, ...] = () + warnings_ids: tuple[str, ...] = () + action: str | None = None + reason_code: str | None = None + previous_issue_url: str | None = None + previous_issue_state: str | None = None + findings_signature: str | None = None + current_commit_id: str | None = None + previous_commit_id: str | None = None + unsubscribe_detected: bool | None = None + dry_run: bool | None = None + issue_persistence: str | None = None + simulated_issue_url: str | None = None + codemeta_generated: bool | None = None + codemeta_status: str | None = None + file_path: str | None = None + error: str | None = None + + def to_dict(self) -> dict[str, Any]: + """Serialize the record to the JSON payload shape used by report files.""" + return { + "repo_url": self.repo_url, + "platform": self.platform, + "pitfalls_count": self.pitfalls_count, + "warnings_count": self.warnings_count, + "issue_url": self.issue_url, + "analysis_date": self.analysis_date, + "sw_metadata_bot_version": self.sw_metadata_bot_version, + "rsmetacheck_version": self.rsmetacheck_version, + "pitfalls_ids": list(self.pitfalls_ids), + "warnings_ids": list(self.warnings_ids), + "action": self.action, + "reason_code": self.reason_code, + "previous_issue_url": self.previous_issue_url, + "previous_issue_state": self.previous_issue_state, + "findings_signature": self.findings_signature, + "current_commit_id": self.current_commit_id, + "previous_commit_id": self.previous_commit_id, + "unsubscribe_detected": self.unsubscribe_detected, + "dry_run": self.dry_run, + "issue_persistence": self.issue_persistence, + "simulated_issue_url": self.simulated_issue_url, + "codemeta_generated": self.codemeta_generated, + "codemeta_status": self.codemeta_status, + "file": self.file_path, + "error": self.error, + } + + def get_tool_metadata(self) -> "ToolMetadata": + """Retrieve tool versions information""" + sw_metadata_bot_version = ( + self.sw_metadata_bot_version if not None else "unknown" + ) + rsmetacheck_version = self.rsmetacheck_version if not None else "unknown" + return ToolMetadata(sw_metadata_bot_version, rsmetacheck_version) + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "ReportRecord": + """Create a report record from a dictionary payload.""" + return cls( + repo_url=data.get("repo_url"), + platform=data.get("platform"), + pitfalls_count=data.get("pitfalls_count"), + warnings_count=data.get("warnings_count"), + issue_url=data.get("issue_url"), + analysis_date=data.get("analysis_date"), + sw_metadata_bot_version=data.get("sw_metadata_bot_version"), + rsmetacheck_version=data.get("rsmetacheck_version"), + pitfalls_ids=tuple(data.get("pitfalls_ids") or ()), + warnings_ids=tuple(data.get("warnings_ids") or ()), + action=data.get("action"), + reason_code=data.get("reason_code"), + previous_issue_url=data.get("previous_issue_url"), + previous_issue_state=data.get("previous_issue_state"), + findings_signature=data.get("findings_signature"), + current_commit_id=data.get("current_commit_id"), + previous_commit_id=data.get("previous_commit_id"), + unsubscribe_detected=data.get("unsubscribe_detected"), + dry_run=data.get("dry_run"), + issue_persistence=data.get("issue_persistence"), + simulated_issue_url=data.get("simulated_issue_url"), + codemeta_generated=data.get("codemeta_generated"), + codemeta_status=data.get("codemeta_status"), + file_path=data.get("file"), + error=data.get("error"), + ) + + +@dataclass(frozen=True) +class ToolMetadata: + """Intermediate class to represent the sw-metadata-bot metadata""" + + sw_metadata_bot_version: str = "unknown" + rs_metacheck_version: str = "unknown" + + def to_dict(self): + """Convert to dict""" + return { + "sw_metadata_bot_version": self.sw_metadata_bot_version, + "rsmetacheck_version": self.rs_metacheck_version, + } + + +@dataclass(frozen=True) +class RunReport: + """Structured representation of a run_report.json payload.""" + + run_metadata: dict[str, Any] + counters: dict[str, int] + records: tuple[ReportRecord] + + def to_payload(self) -> dict[str, Any]: + """Serialize the report to the JSON payload shape used on disk.""" + return { + "run_metadata": self.run_metadata, + "counters": self.counters, + "records": [record.to_dict() for record in self.records], + } + + def get_tool_metadata(self) -> "ToolMetadata": + """Retrieve metadata from the first ReportRecord. + Requires record and that the first record to be set correctly. + """ + if self.records: + sample_record: ReportRecord = self.records[0] + return sample_record.get_tool_metadata() + else: + return ToolMetadata() + + def relative_to_run_root(path: Path | None, run_root: Path) -> str | None: """Return a run-root-relative path string. @@ -62,6 +205,15 @@ def relative_to_run_root(path: Path | None, run_root: Path) -> str | None: return str(path) +def _normalize_record(record: dict[str, object] | ReportRecord) -> dict[str, object]: + """Return a JSON-serializable representation of a record.""" + if isinstance(record, ReportRecord): + return record.to_dict() + if isinstance(record, dict): + return dict(record) + raise TypeError(f"Unsupported report record type: {type(record)!r}") + + def build_counters(records: list[dict[str, object]]) -> dict[str, int]: """Build unified counters from report records.""" return { @@ -76,8 +228,10 @@ def build_counters(records: list[dict[str, object]]) -> dict[str, int]: 1 for r in records if r.get("action") == constants.ACTION_UPDATED_BY_COMMENT ), "closed": sum(1 for r in records if r.get("action") == constants.ACTION_CLOSED), - "skipped": sum(1 for r in records if r.get("action") == "skipped"), - "failed": sum(1 for r in records if r.get("action") == "failed"), + "skipped": sum( + 1 for r in records if r.get("action") == constants.ACTION_SKIPPED + ), + "failed": sum(1 for r in records if r.get("action") == constants.ACTION_FAILED), } @@ -157,6 +311,23 @@ def build_record_entry( return entry +def load_report(report_file: Path | str) -> RunReport: + """Load a run_report.json payload and return a normalized report object.""" + report_path = Path(report_file) + with open(report_path, encoding="utf-8") as handle: + payload = json.load(handle) + + records = tuple( + ReportRecord.from_dict(record) if isinstance(record, dict) else record + for record in payload.get("records", []) + ) + return RunReport( + run_metadata=payload.get("run_metadata", {}), + counters=payload.get("counters", {}), + records=records, + ) + + def write_report_file( *, report_file: Path, @@ -170,9 +341,10 @@ def write_report_file( """Write a report payload to disk and return the payload.""" normalized_records: list[dict[str, object]] = [] for record in records: - if isinstance(record, dict): - record.setdefault("unsubscribe_detected", False) - normalized_records.append(record) + normalized_record = _normalize_record(record) + if isinstance(normalized_record, dict): + normalized_record.setdefault("unsubscribe_detected", False) + normalized_records.append(normalized_record) payload = { "run_metadata": build_run_metadata( diff --git a/src/sw_metadata_bot/utils.py b/src/sw_metadata_bot/utils.py index a316e9b..4a212b0 100644 --- a/src/sw_metadata_bot/utils.py +++ b/src/sw_metadata_bot/utils.py @@ -12,8 +12,6 @@ from pathlib import Path from typing import Any, TypeVar, cast -import click - logger = logging.getLogger(__name__) T = TypeVar("T") @@ -85,253 +83,3 @@ def load_json_file( raise ValueError(msg) return cast(dict[str, Any], data) - - -def load_json_file_list( - path: Path, - required: bool = True, - description: str = "JSON file", -) -> list[dict[str, Any]]: - """Load a JSON file containing a list of dictionaries. - - Useful for report files that store arrays of records. - - Args: - path: Path to the JSON file - required: If True, raise if file missing. If False, return empty list. - description: File description for error messages - - Returns: - List of dictionaries, or [] if file missing and not required - - Raises: - FileNotFoundError: If required=True and file missing - json.JSONDecodeError: If file is not valid JSON - ValueError: If file contents are not a list - - Examples: - Load run_report.json records: - >>> records = load_json_file_list( - ... analysis_root / "run_report.json", - ... required=True, - ... description="run report" - ... ) - """ - path = Path(path) if isinstance(path, str) else path - - if not path.exists(): - if required: - msg = f"Required {description} not found: {path}" - logger.error(msg) - raise FileNotFoundError(msg) - logger.debug(f"Optional {description} not found: {path}, returning empty list") - return [] - - try: - with open(path, encoding="utf-8") as f: - data = json.load(f) - except json.JSONDecodeError as exc: - msg = f"Invalid JSON in {description} {path}: {exc}" - logger.error(msg) - raise - - if not isinstance(data, list): - msg = ( - f"Invalid {description} format in {path}: " - f"expected list/array, got {type(data).__name__}" - ) - logger.error(msg) - raise ValueError(msg) - - return cast(list[dict[str, Any]], data) - - -# ============================================================================= -# Configuration Validation -# ============================================================================= - - -def validate_config_field( - config: dict[str, Any], - field: str, - expected_type: type | tuple[type, ...], - error_msg: str | None = None, -) -> Any: - """Validate that a configuration field exists and has the expected type. - - This utility centralizes the repeated pattern of checking config fields, - reducing duplication and ensuring consistent error messages. - - Args: - config: Configuration dictionary - field: Field name to check - expected_type: Expected type(s), e.g., str, (str, int), list - error_msg: Custom error message. If None, generates one automatically. - - Returns: - The field value if validation passes - - Raises: - click.ClickException: If field missing or has wrong type - - Examples: - Check that config has a list of repositories: - >>> repos = validate_config_field( - ... config, "repositories", list, - ... error_msg="repositories must be a list of repository URLs" - ... ) - - Check for either string or Path: - >>> output = validate_config_field( - ... config, "output_root", (str, Path) - ... ) - """ - value = config.get(field) - - if not isinstance(value, expected_type): - if error_msg: - message = error_msg - else: - type_names = ( - expected_type.__name__ - if isinstance(expected_type, type) - else " or ".join(t.__name__ for t in expected_type) - ) - message = ( - f"Configuration field '{field}' must be {type_names}, " - f"got {type(value).__name__}" - ) - logger.error(message) - raise click.ClickException(message) - - return value - - -def validate_config_required( - config: dict[str, Any], - fields: list[str], -) -> None: - """Validate that all required fields are present in config. - - Args: - config: Configuration dictionary - fields: List of required field names - - Raises: - click.ClickException: If any required field is missing - - Examples: - >>> validate_config_required(config, ["repositories", "outputs"]) - """ - missing = [f for f in fields if f not in config] - if missing: - msg = f"Configuration missing required fields: {', '.join(missing)}" - logger.error(msg) - raise click.ClickException(msg) - - -# ============================================================================= -# Path Management -# ============================================================================= - - -def build_file_path_safely( - base: Path, - *parts: str, - allow_parent_traversal: bool = False, -) -> Path: - """Construct a file path safely, preventing directory traversal attacks. - - This utility prevents malicious or accidental use of ".." in path components - to escape the intended directory. Useful when constructing paths from - configuration or external input. - - Args: - base: Base directory path - *parts: Path components to append - allow_parent_traversal: If False, raise on ".." components (recommended). - If True, ".." is processed normally. - - Returns: - Constructed Path object - - Raises: - ValueError: If allow_parent_traversal=False and ".." detected in parts - - Examples: - Safe path construction from config: - >>> repo_folder = build_file_path_safely( - ... analysis_root, sanitize_repo_name(repo_url) - ... ) - - With parent traversal check (recommended): - >>> report_path = build_file_path_safely( - ... output_root, "analysis", "report.json", - ... allow_parent_traversal=False - ... ) - """ - if not allow_parent_traversal: - for part in parts: - if ".." in part or part.startswith("/"): - msg = f"Unsafe path component detected (potential directory traversal): {part}" - logger.error(msg) - raise ValueError(msg) - - path = Path(base) - for part in parts: - path = path / part - - return path - - -# ============================================================================= -# Type Guards & Validators -# ============================================================================= - - -def is_dict_with_keys( - value: Any, - required_keys: list[str] | None = None, -) -> bool: - """Check if a value is a dict and optionally has required keys. - - Useful for defensive programming when parsing external data. - - Args: - value: Value to check - required_keys: If provided, check that all these keys exist in dict - - Returns: - True if value is a dict (and has required keys if provided), else False - - Examples: - >>> check_data = parse_json_response(...) - >>> if is_dict_with_keys(check_data, ["@type", "@context"]): - ... process_check(check_data) - """ - if not isinstance(value, dict): - return False - - if required_keys is not None: - return all(key in value for key in required_keys) - - return True - - -def ensure_list(value: Any) -> list[Any]: - """Convert value to a list, handling None and single values. - - Args: - value: Value to convert (can be None, single item, or list) - - Returns: - List containing the value(s), or empty list if value is None - - Examples: - >>> ids = ensure_list(record.get("check_ids")) # Handle None gracefully - """ - if value is None: - return [] - if isinstance(value, list): - return value - return [value] diff --git a/tests/test_report_summary.py b/tests/test_report_summary.py new file mode 100644 index 0000000..6bf31a4 --- /dev/null +++ b/tests/test_report_summary.py @@ -0,0 +1,75 @@ +import json + +from sw_metadata_bot.report_summary import summarize_analysis_folder +from sw_metadata_bot.reporting import load_report + + +def test_load_report_and_summarize_analysis_folder(tmp_path): + report_file = tmp_path / "run_report.json" + report_file.write_text( + json.dumps( + { + "run_metadata": {"dry_run": False}, + "counters": {"total": 3}, + "records": [ + { + "repo_url": "https://github.com/example/one", + "platform": "github", + "pitfalls_count": 2, + "warnings_count": 3, + "pitfalls_ids": ["P001", "P002"], + "warnings_ids": ["W001"], + "action": "created", + "reason_code": "missing_codemeta", + }, + { + "repo_url": "https://github.com/example/two", + "platform": "github", + "pitfalls_count": 1, + "warnings_count": 2, + "pitfalls_ids": ["P001"], + "warnings_ids": ["W001", "W002"], + "action": "skipped", + "reason_code": "same_repository", + }, + { + "repo_url": "https://github.com/example/three", + "platform": "github", + "pitfalls_count": 0, + "warnings_count": 1, + "pitfalls_ids": [], + "warnings_ids": ["W002"], + "action": "skipped", + "reason_code": "same_analysis", + }, + ], + } + ) + ) + + report = load_report(report_file) + summary = summarize_analysis_folder(tmp_path) + + assert report.run_metadata["dry_run"] is False + assert report.records[0].pitfalls_ids == ("P001", "P002") + + assert summary["repository_count"] == 3 + assert summary["pitfalls_by_id"] == {"P001": 2, "P002": 1} + assert summary["warnings_by_id"] == {"W001": 2, "W002": 2} + assert summary["total_pitfalls"] == 3 + assert summary["total_warnings"] == 6 + assert summary["issues_created"] == 1 + assert summary["actions"] == {"created": 1, "skipped": 2} + assert summary["reason_codes"] == { + "missing_codemeta": 1, + "same_analysis": 1, + "same_repository": 1, + } + assert summary["reason_codes_by_action"] == { + "created": {"missing_codemeta": 1}, + "skipped": {"same_analysis": 1, "same_repository": 1}, + } + assert summary["reason_code_percentages_by_action"] == { + "created": {"missing_codemeta": 100.0}, + "skipped": {"same_analysis": 50.0, "same_repository": 50.0}, + } diff --git a/uv.lock b/uv.lock index 53e22db..e804d19 100644 --- a/uv.lock +++ b/uv.lock @@ -5053,7 +5053,7 @@ wheels = [ [[package]] name = "sw-metadata-bot" -version = "0.5.3" +version = "0.5.4.dev1" source = { editable = "." } dependencies = [ { name = "click" },