diff --git a/src/sw_metadata_bot/analysis_runtime.py b/src/sw_metadata_bot/analysis_runtime.py index d78d989..85e7e9a 100644 --- a/src/sw_metadata_bot/analysis_runtime.py +++ b/src/sw_metadata_bot/analysis_runtime.py @@ -119,6 +119,7 @@ def _load_current_analysis_context( def _load_previous_analysis_context( previous_record: dict[str, object] | None, current_commit_id: str | None, + force_analysis: bool = False, ) -> PreviousAnalysisContext: """Load previous-analysis state used by the incremental decision tree.""" if previous_record is None: @@ -170,7 +171,7 @@ def _load_previous_analysis_context( ) repo_updated = True - if ( + if not force_analysis and ( previous_commit_id and current_commit_id and previous_commit_id != "Unknown" @@ -657,6 +658,7 @@ def create_analysis_record( current_commit_id: str | None, dry_run: bool, custom_message: str | None, + force_analysis: bool = False, ) -> dict[str, object]: """Create a decision record for a repository without platform API calls.""" pitfall_file = repo_folder / constants.FILENAME_PITFALL @@ -703,7 +705,9 @@ def create_analysis_record( platform = detect_repo_platform(repo_url) previous_analysis = _load_previous_analysis_context( - previous_record, current_commit_id + previous_record, + current_commit_id, + force_analysis=force_analysis, ) decision = incremental.evaluate( diff --git a/src/sw_metadata_bot/pipeline.py b/src/sw_metadata_bot/pipeline.py index 8f75735..aee134b 100644 --- a/src/sw_metadata_bot/pipeline.py +++ b/src/sw_metadata_bot/pipeline.py @@ -130,8 +130,13 @@ def run_pipeline( dry_run: bool, snapshot_tag: str | None, previous_report: Path | None, + force_analysis: bool = False, ) -> None: - """Run analysis and write issue decision records without API side effects.""" + """Run analysis and write issue decision records without API side effects. + + When force_analysis is True, the pipeline will bypass artifact reuse for + unchanged repositories and treat them as if the repository was updated. + """ config = load_config(config_file) repositories = get_repositories(config) custom_message = get_custom_message(config) @@ -187,7 +192,7 @@ def run_pipeline( current_commit_id = None reused_previous = False - if ( + if not force_analysis and ( previous_snapshot_root is not None and previous_record is not None and previous_commit_id @@ -245,6 +250,7 @@ def run_pipeline( current_commit_id=current_commit_id, dry_run=dry_run, custom_message=custom_message, + force_analysis=force_analysis, ) analysis_runtime.write_analysis_repo_report( @@ -300,10 +306,17 @@ def run_pipeline( default=None, help="Previous run_report.json used for incremental issue handling.", ) +@click.option( + "--force-analysis", + is_flag=True, + default=False, + help="Force analysis even when the repository commit id is unchanged.", +) def run_analysis_command( config_file: Path, snapshot_tag: str | None, previous_report: Path | None, + force_analysis: bool, ) -> None: """Run analysis and compute issue lifecycle decisions in dry-run mode.""" run_pipeline( @@ -311,4 +324,5 @@ def run_analysis_command( dry_run=True, snapshot_tag=snapshot_tag, previous_report=previous_report, + force_analysis=force_analysis, ) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index bd28e4d..a3c5e98 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -387,6 +387,7 @@ def fake_run_pipeline(**kwargs): "2026-03", "--previous-report", str(config), + "--force-analysis", ], ) @@ -396,6 +397,7 @@ def fake_run_pipeline(**kwargs): "dry_run": True, "snapshot_tag": "2026-03", "previous_report": config, + "force_analysis": True, } @@ -1016,3 +1018,85 @@ def fake_run_rsmetacheck(**kwargs): updated_config = json.loads(config.read_text()) assert updated_config["issues"]["opt_outs"] == [] + + +def test_run_pipeline_force_analysis_reruns_metacheck_when_commit_unchanged( + monkeypatch, tmp_path +): + """Force-analysis mode bypasses artifact reuse for unchanged repositories.""" + called = {"rsmetacheck": False} + + def fake_run_rsmetacheck(**kwargs): + called["rsmetacheck"] = True + + monkeypatch.setattr(analysis_runtime, "run_rsmetacheck", fake_run_rsmetacheck) + monkeypatch.setattr( + commit_lookup, "get_repo_head_commit", lambda repo_url: "abc123" + ) + + output_root = tmp_path / "outputs" + config = _write_config( + tmp_path, + repositories=["https://github.com/example/repo"], + outputs={ + "root_dir": str(output_root), + "run_name": "batch-a", + "snapshot_tag_format": None, + }, + ) + + prev_root = output_root / "batch-a" / "20260310" + prev_repo = prev_root / "github_com_example_repo" + prev_repo.mkdir(parents=True) + (prev_repo / "somef_output.json").write_text("{}") + (prev_repo / "pitfall.jsonld").write_text( + json.dumps( + { + "assessedSoftware": {"url": "https://github.com/example/repo"}, + "checks": [], + } + ) + ) + (prev_repo / "report.json").write_text( + json.dumps( + { + "records": [ + { + "repo_url": "https://github.com/example/repo", + "issue_url": "https://github.com/example/repo/issues/7", + "issue_persistence": "posted", + "current_commit_id": "abc123", + } + ] + } + ) + ) + (prev_root / "run_report.json").write_text( + json.dumps( + { + "records": [ + { + "repo_url": "https://github.com/example/repo", + "current_commit_id": "abc123", + } + ] + } + ) + ) + + pipeline.run_pipeline( + config_file=config, + dry_run=True, + snapshot_tag="20260311", + previous_report=None, + force_analysis=True, + ) + + assert called["rsmetacheck"] is True + + report_path = ( + output_root / "batch-a" / "20260311" / "github_com_example_repo" / "report.json" + ) + assert report_path.exists() + report = json.loads(report_path.read_text()) + assert report["records"][0]["reason_code"] != "repo_not_updated"