diff --git a/scripts/ci/opencode_review_normalize_output.py b/scripts/ci/opencode_review_normalize_output.py index c7cbeb63..c6cc0450 100755 --- a/scripts/ci/opencode_review_normalize_output.py +++ b/scripts/ci/opencode_review_normalize_output.py @@ -315,21 +315,26 @@ def mentions_changed_file_evidence(reason: str, summary: str) -> bool: return bool(CHANGED_FILE_EVIDENCE_PATTERN.search(f"{reason}\n{summary}")) -def current_changed_files() -> set[str]: - """Return the exact current-head changed files when the workflow provides them.""" +def current_changed_files() -> set[str] | None: + """Return the exact current-head changed files when the workflow provides them, or None if unavailable.""" changed_files_path = os.environ.get("OPENCODE_CHANGED_FILES_FILE") if not changed_files_path: - return set() + return None + + path = Path(changed_files_path) + if not path.is_file(): + return None + try: return { line.strip() - for line in Path(changed_files_path) + for line in path .read_text(encoding="utf-8") .splitlines() if line.strip() } except OSError: - return set() + return None def changed_file_is_source_like(path: str) -> bool: @@ -404,8 +409,10 @@ def contradicts_material_changed_file_scope(reason: str, summary: str) -> bool: def mentions_actual_changed_file(reason: str, summary: str) -> bool: """Return whether an approval names an exact current-head changed file.""" changed_files = current_changed_files() - if not changed_files: + if changed_files is None: return mentions_changed_file_evidence(reason, summary) + if not changed_files: + return True combined = f"{reason}\n{summary}" return any(changed_file in combined for changed_file in changed_files) @@ -572,13 +579,18 @@ def build_approval_repair_summary(summary: str, evidence_text: str) -> str | Non """Append missing approval labels from bounded current-head evidence.""" changed_files = changed_files_from_evidence(evidence_text) coverage_mode = evidence_coverage_mode(evidence_text) - if not changed_files or coverage_mode is None: + if coverage_mode is None: return None - first_file = changed_files[0] - file_list = ", ".join(changed_files[:5]) - if len(changed_files) > 5: - file_list += f", and {len(changed_files) - 5} more" + if not changed_files: + first_file = "no-files-changed" + file_list = "no files changed" + else: + first_file = changed_files[0] + file_list = ", ".join(changed_files[:5]) + if len(changed_files) > 5: + file_list += f", and {len(changed_files) - 5} more" + if coverage_mode == "not_applicable": coverage_line = ( "Coverage: coverage execution evidence reports test coverage as not applicable " diff --git a/tests/test_opencode_review_normalize_output.py b/tests/test_opencode_review_normalize_output.py index fcc7d37d..b7e3ac54 100644 --- a/tests/test_opencode_review_normalize_output.py +++ b/tests/test_opencode_review_normalize_output.py @@ -92,7 +92,7 @@ def test_changed_file_and_verification_posture_detection(): def test_actual_changed_file_detection_prefers_current_head_file_list(tmp_path, monkeypatch): monkeypatch.delenv("OPENCODE_CHANGED_FILES_FILE", raising=False) - assert norm.current_changed_files() == set() + assert norm.current_changed_files() is None assert norm.mentions_actual_changed_file("scripts/ci/example.py", "") changed_files = tmp_path / "changed-files.txt" @@ -126,9 +126,27 @@ def test_actual_changed_file_detection_prefers_current_head_file_list(tmp_path, ) monkeypatch.setenv("OPENCODE_CHANGED_FILES_FILE", str(tmp_path / "missing.txt")) - assert norm.current_changed_files() == set() + assert norm.current_changed_files() is None assert norm.mentions_actual_changed_file("scripts/ci/example.py", "") + empty_files = tmp_path / "empty-files.txt" + empty_files.write_text("", encoding="utf-8") + monkeypatch.setenv("OPENCODE_CHANGED_FILES_FILE", str(empty_files)) + assert norm.current_changed_files() == set() + assert norm.mentions_actual_changed_file("No files changed", "Empty PR") + + dir_path = tmp_path / "a_directory" + dir_path.mkdir() + monkeypatch.setenv("OPENCODE_CHANGED_FILES_FILE", str(dir_path)) + assert norm.current_changed_files() is None + + def raise_os_error(*args, **kwargs): + raise OSError("Permission denied") + + monkeypatch.setattr(norm.Path, "read_text", raise_os_error) + monkeypatch.setenv("OPENCODE_CHANGED_FILES_FILE", str(changed_files)) + assert norm.current_changed_files() is None + def test_preferred_review_language_handles_unreadable_and_unknown_evidence(tmp_path, monkeypatch): evidence = tmp_path / "evidence.md" @@ -485,6 +503,29 @@ def test_valid_control_filters_shape_head_and_review_contract(): def test_valid_control_repairs_approval_summary_from_bounded_evidence(tmp_path, monkeypatch): + evidence = tmp_path / "bounded-review-evidence-empty.md" + evidence.write_text( + """\ +# OpenCode bounded PR review evidence + +## Coverage execution evidence + +# Coverage Evidence + +## Coverage Decision + +- Result: PASS +- Test coverage: not applicable because no supported changed source files or package manifests were found. +- Docstring coverage: not applicable + +## Changed files +""", + encoding="utf-8", + ) + monkeypatch.setenv("OPENCODE_EVIDENCE_FILE", str(evidence)) + monkeypatch.delenv("OPENCODE_CHANGED_FILES_FILE", raising=False) + assert norm.build_approval_repair_summary("", evidence.read_text(encoding="utf-8")) is not None + evidence = tmp_path / "bounded-review-evidence.md" evidence.write_text( """\