diff --git a/repo_policy_sync/src/github.py b/repo_policy_sync/src/github.py index a323e0c..9fa2ff0 100644 --- a/repo_policy_sync/src/github.py +++ b/repo_policy_sync/src/github.py @@ -513,6 +513,7 @@ def create_pull_request( changes: tuple[Change, ...], head_oid: str, draft: bool = False, + tool_revision: str, ) -> PullRequest: self._ensure_automation_labels(repository=repository) create_command = [ @@ -528,7 +529,12 @@ def create_pull_request( "--title", policy.title, "--body", - _pull_request_body(policy, changes, head_oid=head_oid), + _pull_request_body( + policy, + changes, + head_oid=head_oid, + tool_revision=tool_revision, + ), ] if draft: create_command.insert(3, "--draft") @@ -642,6 +648,7 @@ def update_pull_request( changes: tuple[Change, ...], head_oid: str, failure: str | None = None, + tool_revision: str, ) -> None: """Keep an existing policy-owned pull request's explanation current.""" @@ -658,7 +665,7 @@ def update_pull_request( "-f", f"title={policy.title}", "-f", - f"body={_pull_request_body(policy, changes, head_oid=head_oid, failure=failure)}", + f"body={_pull_request_body(policy, changes, head_oid=head_oid, failure=failure, tool_revision=tool_revision)}", ] ) @@ -785,11 +792,52 @@ 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, ...], *, head_oid: str, + tool_revision: str, failure: str | None = None, ) -> str: """Build the concise, policy-centred pull-request template.""" @@ -815,6 +863,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/src/runner.py b/repo_policy_sync/src/runner.py index 2a82a59..9ef1695 100644 --- a/repo_policy_sync/src/runner.py +++ b/repo_policy_sync/src/runner.py @@ -36,6 +36,7 @@ PolicyPullRequestStatus, TOOL_SLUG, _pull_request_body, + _tool_revision, policy_branches, ) from .models import Change, Policy, Repository @@ -107,6 +108,7 @@ def create_pull_request( changes: tuple[Change, ...], head_oid: str, draft: bool = False, + tool_revision: str, ) -> object: ... def update_pull_request( @@ -118,6 +120,7 @@ def update_pull_request( changes: tuple[Change, ...], head_oid: str, failure: str | None = None, + tool_revision: str, ) -> None: ... def close_pull_request(self, *, repository: str, pull_request: object) -> None: ... @@ -187,6 +190,7 @@ def run_policies( policy_workers: int = DEFAULT_POLICY_WORKERS, progress: Callable[[str], None] | None = None, include_pull_request_status: bool = False, + tool_revision: str | None = None, ) -> RunReport: """Synchronize repositories, then process each policy across repositories in parallel. @@ -198,6 +202,10 @@ def run_policies( raise RepoPolicySyncError("policy worker count must be at least 1") if recreate and not apply: raise RepoPolicySyncError("--recreate requires apply mode") + if apply and tool_revision is None: + # Resolve provenance before repository synchronization or any policy + # branch can be changed remotely by the apply workflow. + tool_revision = _tool_revision() started = monotonic() report_progress = progress or _write_progress try: @@ -251,6 +259,7 @@ def run_policies( workers=policy_workers, progress=report_progress, include_pull_request_status=include_pull_request_status, + tool_revision=tool_revision, ) outcomes.extend(policy_outcomes) for outcome in policy_outcomes: @@ -324,6 +333,7 @@ def _run_policy_across_repositories( workers: int, progress: Callable[[str], None], include_pull_request_status: bool, + tool_revision: str | None, ) -> tuple[RepositoryOutcome, ...]: """Evaluate or apply one policy in independent repository checkouts concurrently.""" @@ -358,6 +368,7 @@ def _run_policy_across_repositories( recreate=recreate, allow_dirty_pr=allow_dirty_pr, include_pull_request_status=include_pull_request_status, + tool_revision=tool_revision, ) ] = (index, repository) for completed, future in enumerate(as_completed(futures), start=1): @@ -390,6 +401,7 @@ def _run_policy_in_repository( recreate: bool, allow_dirty_pr: bool, include_pull_request_status: bool, + tool_revision: str | None, ) -> RepositoryOutcome: restore_synced_default_branch(checkout=checkout) if ( @@ -407,6 +419,7 @@ def _run_policy_in_repository( recreate=recreate, allow_dirty_pr=allow_dirty_pr, include_pull_request_status=include_pull_request_status, + tool_revision=tool_revision, ) @@ -422,7 +435,12 @@ def _run_repository( recreate: bool = False, allow_dirty_pr: bool = False, include_pull_request_status: bool = False, + tool_revision: str | None = None, ) -> RepositoryOutcome: + if apply and tool_revision is None: + # Keep direct private callers safe as well as the organization-level + # entry point: provenance must be known before branch mutation. + tool_revision = _tool_revision() full_name = f"{org}/{repository}" policy_pr_status = ( _find_policy_pull_request_status( @@ -461,6 +479,7 @@ def _run_repository( changes=(), head_oid=existing_pr.expected_head_oid, failure=str(exc), + tool_revision=tool_revision, ) client.close_pull_request( repository=full_name, pull_request=existing_pr @@ -508,6 +527,7 @@ def _run_repository( policy=policy, checkout=checkout, allow_dirty_pr=allow_dirty_pr, + tool_revision=tool_revision, ) if not evaluation.changes: existing_pr = ( @@ -615,6 +635,7 @@ def _run_repository( changes=evaluation.changes, head_oid=existing_pr.expected_head_oid, failure=str(exc), + tool_revision=tool_revision, ) client.close_pull_request( repository=full_name, pull_request=existing_pr @@ -637,12 +658,14 @@ def _run_repository( existing_pr=existing_pr, changes=evaluation.changes, allow_dirty_pr=allow_dirty_pr, + tool_revision=tool_revision, ) if _pull_request_body_changed( existing_pr, policy=policy, changes=evaluation.changes, head_oid=existing_pr.expected_head_oid, + tool_revision=tool_revision, ): client.update_pull_request( repository=full_name, @@ -650,6 +673,7 @@ def _run_repository( policy=policy, changes=evaluation.changes, head_oid=existing_pr.expected_head_oid, + tool_revision=tool_revision, ) return RepositoryOutcome( repository, @@ -686,6 +710,7 @@ def _run_repository( changes=applied.changes, head_oid=head_oid, draft=pre_commit_failure is not None, + tool_revision=tool_revision, ) if pre_commit_failure is not None: _comment_dirty_pull_request( @@ -710,6 +735,7 @@ def _run_repository( policy=policy, changes=applied.changes, head_oid=head_oid, + tool_revision=tool_revision, ) if pre_commit_failure is not None: _mark_dirty_pull_request( @@ -796,11 +822,17 @@ def _pull_request_body_changed( policy: Policy, changes: tuple[Change, ...], head_oid: str, + tool_revision: str, ) -> bool: """Return whether the generated explanation differs from the PR body.""" body = getattr(pull_request, "body", None) - return body != _pull_request_body(policy, changes, head_oid=head_oid) + return body != _pull_request_body( + policy, + changes, + head_oid=head_oid, + tool_revision=tool_revision, + ) def _commit_result_parts(result: CommitResult) -> tuple[str, str | None]: @@ -833,6 +865,7 @@ def _recreate_repository( policy: Policy, checkout: Path, allow_dirty_pr: bool = False, + tool_revision: str | None = None, ) -> RepositoryOutcome: """Rebuild an existing policy branch from the freshly synced default branch.""" @@ -856,6 +889,7 @@ def _recreate_repository( checkout=checkout, existing_pr=existing_pr, allow_dirty_pr=allow_dirty_pr, + tool_revision=tool_revision, ) @@ -870,6 +904,7 @@ def _recreate_existing_pull_request( existing_pr: object, changes: tuple[Change, ...] | None = None, allow_dirty_pr: bool = False, + tool_revision: str | None = None, ) -> RepositoryOutcome: """Rebuild one known policy PR from the freshly synchronized default branch.""" @@ -896,6 +931,7 @@ def _recreate_existing_pull_request( policy=policy, changes=body_changes, head_oid=existing_pr.expected_head_oid, + tool_revision=tool_revision, ): client.update_pull_request( repository=full_name, @@ -903,6 +939,7 @@ def _recreate_existing_pull_request( policy=policy, changes=body_changes, head_oid=existing_pr.expected_head_oid, + tool_revision=tool_revision, ) return RepositoryOutcome( repository, @@ -927,6 +964,7 @@ def _recreate_existing_pull_request( policy=policy, changes=applied.changes, head_oid=head_oid, + tool_revision=tool_revision, ) if pre_commit_failure is not None: _mark_dirty_pull_request( diff --git a/repo_policy_sync/templates/pull_request.md b/repo_policy_sync/templates/pull_request.md index 74c1146..23beb8c 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 [eclipse-score/tools](https://github.com/eclipse-score/tools) at commit `{{ tool_revision }}`. + {{ failure_section }} --- diff --git a/repo_policy_sync/tests/test_github.py b/repo_policy_sync/tests/test_github.py index 4e9b92e..c453919 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 @@ -457,6 +458,7 @@ def run(command: list[str]) -> str: policy=policy, changes=(), head_oid="a" * 40, + tool_revision="test-revision", ) assert pull_request.url == "https://github.example/owner/repo/pull/1" @@ -510,6 +512,7 @@ def run(command: list[str]) -> str: policy=policy, changes=(), head_oid="a" * 40, + tool_revision="test-revision", ) assert not any(command[4] == "/repos/owner/repo/labels" for command in commands) @@ -573,6 +576,7 @@ def run(command: list[str]) -> str: policy=policy, changes=(), head_oid="a" * 40, + tool_revision="test-revision", ) @@ -606,6 +610,7 @@ def run(command: list[str]) -> str: changes=(), head_oid="a" * 40, draft=True, + tool_revision="test-revision", ) assert commands[1][:4] == ["gh", "pr", "create", "--draft"] @@ -621,7 +626,10 @@ def test_pull_request_template_explains_policy_trigger_and_changes() -> None: ) body = _pull_request_body( - policy, (Change(Path(".gitignore"), "add '_build'"),), head_oid="a" * 40 + policy, + (Change(Path(".gitignore"), "add '_build'"),), + head_oid="a" * 40, + tool_revision="abc1234-dirty", ) assert "" in body @@ -633,12 +641,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 [eclipse-score/tools](https://github.com/eclipse-score/tools) " + "at commit `abc1234-dirty`." in body + ) assert body.index("## Policy") < body.index("