From 698c61d971125303922efcf6926546d7eae42136 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 11 Sep 2026 06:09:19 +0200 Subject: [PATCH 1/3] feat(repo-policy-sync): record tool checkout revision --- repo_policy_sync/src/github.py | 41 ++++++++++++++++ repo_policy_sync/templates/pull_request.md | 4 ++ repo_policy_sync/tests/test_github.py | 56 +++++++++++++++++++++- 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/repo_policy_sync/src/github.py b/repo_policy_sync/src/github.py index a323e0c..260d810 100644 --- a/repo_policy_sync/src/github.py +++ b/repo_policy_sync/src/github.py @@ -785,6 +785,46 @@ def _pull_request_number(url: str) -> int: return int(match.group(1)) +def _tool_revision() -> str: + """Return the current checkout's short commit hash and dirty marker.""" + + try: + revision_result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: + raise CommandError("required command is unavailable: git") from exc + except subprocess.CalledProcessError as exc: + detail = exc.stderr.strip() or exc.stdout.strip() or "command failed" + raise CommandError(f"git rev-parse --short HEAD: {detail}") from exc + + revision = revision_result.stdout.strip() + if not revision: + raise CommandError("git rev-parse --short HEAD returned no commit hash") + + try: + dirty_result = subprocess.run( + ["git", "diff-index", "--quiet", "HEAD", "--"], + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: # pragma: no cover - guarded by rev-parse + raise CommandError("required command is unavailable: git") from exc + + if dirty_result.returncode not in (0, 1): + detail = ( + dirty_result.stderr.strip() + or dirty_result.stdout.strip() + or "command failed" + ) + raise CommandError(f"git diff-index --quiet HEAD --: {detail}") + return f"{revision}-dirty" if dirty_result.returncode == 1 else revision + + def _pull_request_body( policy: Policy, changes: tuple[Change, ...], @@ -815,6 +855,7 @@ def _pull_request_body( "policy_description": description, "policy_trigger": _policy_trigger(policy, changes), "changes": change_lines, + "tool_revision": _tool_revision(), "failure_section": _failure_section(failure), } for key, value in values.items(): diff --git a/repo_policy_sync/templates/pull_request.md b/repo_policy_sync/templates/pull_request.md index 74c1146..06ae315 100644 --- a/repo_policy_sync/templates/pull_request.md +++ b/repo_policy_sync/templates/pull_request.md @@ -25,6 +25,10 @@ {{ changes }} +## Tool revision + +Generated from commit `{{ tool_revision }}` in the checkout where the tool was run. + {{ failure_section }} --- diff --git a/repo_policy_sync/tests/test_github.py b/repo_policy_sync/tests/test_github.py index 4e9b92e..c96d912 100644 --- a/repo_policy_sync/tests/test_github.py +++ b/repo_policy_sync/tests/test_github.py @@ -23,6 +23,7 @@ GitHubCli, PullRequest, _pull_request_body, + _tool_revision, policy_branches, ) from repo_policy_sync.src.errors import CommandError, redact_sensitive_text @@ -611,7 +612,10 @@ def run(command: list[str]) -> str: assert commands[1][:4] == ["gh", "pr", "create", "--draft"] -def test_pull_request_template_explains_policy_trigger_and_changes() -> None: +def test_pull_request_template_explains_policy_trigger_and_changes(monkeypatch) -> None: + monkeypatch.setattr( + "repo_policy_sync.src.github._tool_revision", lambda: "abc1234-dirty" + ) policy = Policy( "score-docs-as-code.cleanup", "Update docs files", @@ -633,12 +637,62 @@ def test_pull_request_template_explains_policy_trigger_and_changes() -> None: ) assert "`MODULE.bazel` declares the required direct Bazel dependency" in body assert "- `.gitignore`: add '_build'" in body + assert ( + "Generated from commit `abc1234-dirty` in the checkout where the tool was run." + in body + ) assert body.index("## Policy") < body.index("" in body @@ -703,6 +707,7 @@ def test_module_policy_pull_request_includes_the_matching_rationale() -> None: policy, (Change(operation.path, "replace matching text", operation.rationale),), head_oid="a" * 40, + tool_revision="test-revision", ) assert "- `MODULE.bazel`: replace matching text" in body @@ -723,6 +728,7 @@ def test_value_policy_pull_request_explains_value_trigger() -> None: policy, (Change(Path("MODULE.bazel"), "add dependency"),), head_oid="a" * 40, + tool_revision="test-revision", ) assert ( @@ -749,6 +755,7 @@ def record(command: list[str]) -> str: policy=policy, changes=(Change(Path(".gitignore"), "add '_build'"),), head_oid="a" * 40, + tool_revision="test-revision", ) assert commands[0][:7] == [ @@ -767,7 +774,11 @@ def test_pull_request_template_includes_automation_failure() -> None: policy = Policy("example", "Example", None, None, ()) body = _pull_request_body( - policy, (), head_oid="a" * 40, failure="bazel mod deps: command failed" + policy, + (), + head_oid="a" * 40, + failure="bazel mod deps: command failed", + tool_revision="test-revision", ) assert "## Automation failure" in body diff --git a/repo_policy_sync/tests/test_runner.py b/repo_policy_sync/tests/test_runner.py index f0153db..ad2ecee 100644 --- a/repo_policy_sync/tests/test_runner.py +++ b/repo_policy_sync/tests/test_runner.py @@ -179,6 +179,7 @@ def create_pull_request( policy: Policy, changes: tuple, head_oid: str, + tool_revision: str, **_: object, ) -> PullRequest: self.create_calls += 1 @@ -187,7 +188,12 @@ def create_pull_request( url=f"https://github.example/{repository}/pull/1", expected_head_oid=head_oid, branch=branch, - body=_pull_request_body(policy, changes, head_oid=head_oid), + body=_pull_request_body( + policy, + changes, + head_oid=head_oid, + tool_revision=tool_revision, + ), mergeable="MERGEABLE", ) return self.pull_request @@ -641,6 +647,36 @@ def fake_sync_org(**_: object) -> SyncReport: ) +def test_apply_resolves_tool_revision_before_repository_synchronization( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + client = FakeRepositoryClient(tmp_path, (Repository("candidate", "main"),)) + sync_called = False + + def fail_tool_revision() -> str: + raise RepoPolicySyncError("tool revision unavailable") + + def unexpected_sync(**_: object) -> SyncReport: + nonlocal sync_called + sync_called = True + raise AssertionError("repository synchronization must not start") + + monkeypatch.setattr(runner, "_tool_revision", fail_tool_revision) + monkeypatch.setattr(runner, "sync_org", unexpected_sync) + + with pytest.raises(RepoPolicySyncError, match="tool revision unavailable"): + run_policies( + client=client, + org="eclipse-score", + policies=(), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=True, + ) + + assert not sync_called + + def test_runner_rejects_recreate_when_a_repository_pattern_selects_multiple( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1283,7 +1319,12 @@ def test_existing_compliant_pull_request_is_left_alone_with_current_body( change = runner.evaluate_policy(checkout, policy).changes client = ImplicitRecreateClient( mergeable="MERGEABLE", - body=_pull_request_body(policy, change, head_oid="a" * 40), + body=_pull_request_body( + policy, + change, + head_oid="a" * 40, + tool_revision="test-revision", + ), ) outcome = _run_repository( @@ -1294,6 +1335,7 @@ def test_existing_compliant_pull_request_is_left_alone_with_current_body( policy=policy, checkout=checkout, apply=True, + tool_revision="test-revision", ) assert outcome.status == "pull-request-open"