diff --git a/repo_policy_sync/docs/explanation/pull-request-safety.md b/repo_policy_sync/docs/explanation/pull-request-safety.md index 9a60f62..1985581 100644 --- a/repo_policy_sync/docs/explanation/pull-request-safety.md +++ b/repo_policy_sync/docs/explanation/pull-request-safety.md @@ -42,12 +42,15 @@ branch-head marker prevents closure and leaves the PR open for human review. ## Generated content -The runtime template is [pull_request.md](../../templates/pull_request.md). It -contains the policy identity and description, the non-compliant files that -triggered the pull request, any satisfied applicability condition, and the +The packaged runtime template is [pull_request.md](../../templates/pull_request.md), +and repositories can select a validated custom template through the +`pull_request_template` configuration setting or `--pull-request-template`. +Templates render the policy identity and description, the non-compliant files +that triggered the pull request, any satisfied applicability condition, and the concrete changed files. A change can include one operation-level rationale; it -is rendered as a nested bullet below that change. The template also states that -the pull request is generated and must be reviewed before merging. +is rendered as a nested bullet below that change. All supported placeholders, +including the ownership and branch-head markers, are required so customized +body text cannot disable PR discovery or safety checks. Repository Policy Sync applies the `automation` and `repo-policy-sync` labels after PR creation. Before creating a PR, it creates either label when it is diff --git a/repo_policy_sync/docs/reference/cli.md b/repo_policy_sync/docs/reference/cli.md index eb591f6..fc3ced6 100644 --- a/repo_policy_sync/docs/reference/cli.md +++ b/repo_policy_sync/docs/reference/cli.md @@ -47,6 +47,7 @@ are CLI-only. | `--json-output PATH` | Also write the versioned JSON report to `PATH`. | | `--markdown-output PATH` | Also write the Markdown report to `PATH`. | | `--policy-dir PATH` | Local policy directory. Repeat to combine directories. Defaults to `./policies` in the current working directory when present. | +| `--pull-request-template PATH` | Pull-request body template. Relative paths are resolved from the current working directory and override `pull_request_template` from TOML. | | `--exclude-policy NAME` | Exclude one local or bundled policy. Applied after any explicit `--policy` selection; repeat to exclude more than one. | | `--recreate` | On `apply`, rebuild one existing policy-owned pull request from its repository's current default branch. Requires exactly one `--repo` selection, exactly one selected repository after pattern expansion, and exactly one `--policy`. | | `--allow-dirty-pr`, `--no-allow-dirty-pr` | After the automatic formatting-fix retry, commit and push changes even if pre-commit still fails; create or keep the pull request as a draft and add a comment with the failure. | diff --git a/repo_policy_sync/docs/reference/configuration.md b/repo_policy_sync/docs/reference/configuration.md index efdb4fa..8e84701 100644 --- a/repo_policy_sync/docs/reference/configuration.md +++ b/repo_policy_sync/docs/reference/configuration.md @@ -38,6 +38,7 @@ recreate = false allow_dirty_pr = false quiet = false cache_dir = ".cache/repo-policy-sync" +pull_request_template = "templates/repo-policy-sync-pull-request.md" sync_workers = 4 policy_workers = 4 ``` @@ -55,6 +56,7 @@ The TOML keys map to the corresponding CLI options as follows: | `allow_dirty_pr` | `--allow-dirty-pr` / `--no-allow-dirty-pr` | | `quiet` | `--quiet` / `--no-quiet` | | `cache_dir` | `--cache-dir` | +| `pull_request_template` | `--pull-request-template` | | `sync_workers` | `--sync-workers` | | `policy_workers` | `--policy-workers` | @@ -65,6 +67,29 @@ directory exists. Setting it to `[]` disables local policy directories. corresponding TOML value, including list-valued options. Unknown policy names and unknown TOML fields are errors. +`pull_request_template` selects the body template for newly created and +updated policy pull requests. Relative TOML paths are resolved relative to the +configuration file. The command-line override resolves relative paths from the +current working directory. If omitted, the packaged +`repo_policy_sync/templates/pull_request.md` template is used. + +Templates must contain each supported placeholder exactly by name; whitespace +inside the braces is allowed: + +| Placeholder | Rendered value | +| --- | --- | +| `{{ policy_id }}` | Policy identifier. | +| `{{ policy_description }}` | Policy description, or the default description. | +| `{{ policy_trigger }}` | Why the repository matches the policy. | +| `{{ changes }}` | Markdown list of changed files and rationales. | +| `{{ failure_section }}` | Automation failure details, or empty text. | +| `{{ policy_marker }}` | Ownership marker used to identify the policy PR. | +| `{{ policy_head_marker }}` | Branch-head marker used by PR safety checks. | + +Missing or unknown placeholders are configuration errors. The ownership and +head markers are mandatory because policy pull-request discovery and safety +checks depend on them. + Values in `repos` use the same repository selection rules as `--repo`: values without `*`, `?`, or `[` are exact names, while values containing those characters use case-sensitive Python `fnmatch` semantics. For example, diff --git a/repo_policy_sync/src/cli.py b/repo_policy_sync/src/cli.py index 95006ba..1cb59f3 100644 --- a/repo_policy_sync/src/cli.py +++ b/repo_policy_sync/src/cli.py @@ -24,7 +24,7 @@ from .config import load_config from .errors import PolicyError, RepoPolicySyncError -from .github import GitHubCli +from .github import GitHubCli, load_pull_request_template from .policy import ( BUNDLED_POLICY_DIRECTORY, DEFAULT_POLICY_DIRECTORY, @@ -123,6 +123,14 @@ def _add_common_arguments( action="append", help="Local policy directory; repeat to combine directories (default: ./policies if present).", ) + rare.add_argument( + "--pull-request-template", + type=Path, + help=( + "Pull-request body template; relative paths are resolved from the " + "current working directory and override the TOML configuration." + ), + ) rare.add_argument( "--exclude-policy", action="append", @@ -203,6 +211,12 @@ def main(argv: Sequence[str] | None = None) -> int: if args.cache_dir is not None else (config.cache_directory or default_cache_directory()) ) + pull_request_template_path = ( + args.pull_request_template + if args.pull_request_template is not None + else config.pull_request_template + ) + pull_request_template = load_pull_request_template(pull_request_template_path) sync_workers = ( args.sync_workers if args.sync_workers is not None @@ -286,6 +300,7 @@ def main(argv: Sequence[str] | None = None) -> int: apply=applying, recreate=recreate, allow_dirty_pr=allow_dirty_pr, + pull_request_template=pull_request_template, sync_workers=sync_workers, policy_workers=policy_workers, # apply already looks up an open PR itself whenever it might act on diff --git a/repo_policy_sync/src/config.py b/repo_policy_sync/src/config.py index f482b8a..985ca44 100644 --- a/repo_policy_sync/src/config.py +++ b/repo_policy_sync/src/config.py @@ -39,6 +39,7 @@ class PolicySyncConfig: allow_dirty_pr: bool | None = None quiet: bool | None = None cache_directory: Path | None = None + pull_request_template: Path | None = None sync_workers: int | None = None policy_workers: int | None = None @@ -93,6 +94,7 @@ def load_config(path: Path | None = None) -> PolicySyncConfig: "allow_dirty_pr", "quiet", "cache_dir", + "pull_request_template", "sync_workers", "policy_workers", } @@ -130,6 +132,16 @@ def load_config(path: Path | None = None) -> PolicySyncConfig: config_path, _string_value(section["cache_dir"], "cache_dir", config_path), ) + pull_request_template = None + if "pull_request_template" in section: + pull_request_template = _resolve_path( + config_path, + _string_value( + section["pull_request_template"], + "pull_request_template", + config_path, + ), + ) sync_workers = _optional_positive_int(section, "sync_workers", config_path) policy_workers = _optional_positive_int(section, "policy_workers", config_path) return PolicySyncConfig( @@ -142,6 +154,7 @@ def load_config(path: Path | None = None) -> PolicySyncConfig: allow_dirty_pr=allow_dirty_pr, quiet=quiet, cache_directory=cache_directory, + pull_request_template=pull_request_template, sync_workers=sync_workers, policy_workers=policy_workers, ) diff --git a/repo_policy_sync/src/github.py b/repo_policy_sync/src/github.py index 9fa2ff0..20c8acd 100644 --- a/repo_policy_sync/src/github.py +++ b/repo_policy_sync/src/github.py @@ -24,7 +24,7 @@ from importlib.resources import files from pathlib import Path -from .errors import CommandError, redact_sensitive_text +from .errors import CommandError, RepoPolicySyncError, redact_sensitive_text from .models import Change, Policy, policy_branch_slug TOOL_SLUG = "repo-policy-sync" @@ -46,6 +46,17 @@ "USER", "LOGNAME", } +PULL_REQUEST_TEMPLATE_PLACEHOLDERS = ( + "policy_id", + "policy_description", + "policy_trigger", + "changes", + "tool_revision", + "failure_section", + "policy_marker", + "policy_head_marker", +) +_PULL_REQUEST_TEMPLATE_PLACEHOLDER = re.compile(r"\{\{([^{}]*)\}\}") @dataclass(frozen=True) @@ -514,6 +525,7 @@ def create_pull_request( head_oid: str, draft: bool = False, tool_revision: str, + pull_request_template: str | None = None, ) -> PullRequest: self._ensure_automation_labels(repository=repository) create_command = [ @@ -534,6 +546,7 @@ def create_pull_request( changes, head_oid=head_oid, tool_revision=tool_revision, + pull_request_template=pull_request_template, ), ] if draft: @@ -649,6 +662,7 @@ def update_pull_request( head_oid: str, failure: str | None = None, tool_revision: str, + pull_request_template: str | None = None, ) -> None: """Keep an existing policy-owned pull request's explanation current.""" @@ -665,7 +679,16 @@ def update_pull_request( "-f", f"title={policy.title}", "-f", - f"body={_pull_request_body(policy, changes, head_oid=head_oid, failure=failure, tool_revision=tool_revision)}", + f"body={ + _pull_request_body( + policy, + changes, + head_oid=head_oid, + failure=failure, + tool_revision=tool_revision, + pull_request_template=pull_request_template, + ) + }", ] ) @@ -839,8 +862,9 @@ def _pull_request_body( head_oid: str, tool_revision: str, failure: str | None = None, + pull_request_template: str | None = None, ) -> str: - """Build the concise, policy-centred pull-request template.""" + """Build a policy pull-request body from the selected validated template.""" description = ( policy.description @@ -851,11 +875,16 @@ def _pull_request_body( + (f"\n - {change.rationale}" if change.rationale else "") for change in changes ) - template = ( - files("repo_policy_sync") - .joinpath("templates/pull_request.md") - .read_text(encoding="utf-8") - ) + if pull_request_template is None: + template = load_pull_request_template() + else: + _validate_pull_request_template( + pull_request_template, source="provided pull-request template" + ) + template = _PULL_REQUEST_TEMPLATE_PLACEHOLDER.sub( + lambda match: f"{{{{ {match.group(1).strip()} }}}}", + pull_request_template, + ) values = { "policy_marker": _policy_marker(policy.id), "policy_head_marker": _policy_head_marker(head_oid), @@ -871,6 +900,57 @@ def _pull_request_body( return template +def load_pull_request_template(path: Path | None = None) -> str: + """Read and validate a packaged or user-provided pull-request template.""" + + source = ( + str(path) if path is not None else "repo_policy_sync/templates/pull_request.md" + ) + try: + template = ( + path.read_text(encoding="utf-8") + if path is not None + else files("repo_policy_sync") + .joinpath("templates/pull_request.md") + .read_text(encoding="utf-8") + ) + except OSError as exc: + raise RepoPolicySyncError( + f"could not read pull-request template {source}: {exc}" + ) from exc + except UnicodeError as exc: + raise RepoPolicySyncError( + f"could not decode pull-request template {source} as UTF-8: {exc}" + ) from exc + _validate_pull_request_template(template, source=source) + return template + + +def _validate_pull_request_template(template: str, *, source: str) -> None: + """Reject templates that cannot render complete, safe policy PR bodies.""" + + placeholders = tuple( + placeholder.strip() + for placeholder in _PULL_REQUEST_TEMPLATE_PLACEHOLDER.findall(template) + ) + supported = set(PULL_REQUEST_TEMPLATE_PLACEHOLDERS) + unknown = sorted(set(placeholders) - supported) + missing = [ + placeholder + for placeholder in PULL_REQUEST_TEMPLATE_PLACEHOLDERS + if placeholder not in placeholders + ] + if unknown or missing: + problems = [] + if missing: + problems.append(f"missing placeholders: {', '.join(missing)}") + if unknown: + problems.append(f"unknown placeholders: {', '.join(unknown)}") + raise RepoPolicySyncError( + f"invalid pull-request template {source}: {'; '.join(problems)}" + ) + + def _failure_section(failure: str | None) -> str: if failure is None: return "" diff --git a/repo_policy_sync/src/runner.py b/repo_policy_sync/src/runner.py index 9ef1695..2e5b862 100644 --- a/repo_policy_sync/src/runner.py +++ b/repo_policy_sync/src/runner.py @@ -109,6 +109,7 @@ def create_pull_request( head_oid: str, draft: bool = False, tool_revision: str, + pull_request_template: str | None = None, ) -> object: ... def update_pull_request( @@ -121,6 +122,7 @@ def update_pull_request( head_oid: str, failure: str | None = None, tool_revision: str, + pull_request_template: str | None = None, ) -> None: ... def close_pull_request(self, *, repository: str, pull_request: object) -> None: ... @@ -186,6 +188,7 @@ def run_policies( apply: bool, recreate: bool = False, allow_dirty_pr: bool = False, + pull_request_template: str | None = None, sync_workers: int = DEFAULT_SYNC_WORKERS, policy_workers: int = DEFAULT_POLICY_WORKERS, progress: Callable[[str], None] | None = None, @@ -254,6 +257,7 @@ def run_policies( apply=apply, recreate=recreate, allow_dirty_pr=allow_dirty_pr, + pull_request_template=pull_request_template, sync_failures=sync_failures, skipped_repositories=skipped_repositories, workers=policy_workers, @@ -328,6 +332,7 @@ def _run_policy_across_repositories( apply: bool, recreate: bool, allow_dirty_pr: bool, + pull_request_template: str | None, sync_failures: dict[str, str], skipped_repositories: set[str], workers: int, @@ -367,6 +372,7 @@ def _run_policy_across_repositories( apply=apply, recreate=recreate, allow_dirty_pr=allow_dirty_pr, + pull_request_template=pull_request_template, include_pull_request_status=include_pull_request_status, tool_revision=tool_revision, ) @@ -400,6 +406,7 @@ def _run_policy_in_repository( apply: bool, recreate: bool, allow_dirty_pr: bool, + pull_request_template: str | None, include_pull_request_status: bool, tool_revision: str | None, ) -> RepositoryOutcome: @@ -418,6 +425,7 @@ def _run_policy_in_repository( apply=apply, recreate=recreate, allow_dirty_pr=allow_dirty_pr, + pull_request_template=pull_request_template, include_pull_request_status=include_pull_request_status, tool_revision=tool_revision, ) @@ -434,6 +442,7 @@ def _run_repository( apply: bool, recreate: bool = False, allow_dirty_pr: bool = False, + pull_request_template: str | None = None, include_pull_request_status: bool = False, tool_revision: str | None = None, ) -> RepositoryOutcome: @@ -480,6 +489,7 @@ def _run_repository( head_oid=existing_pr.expected_head_oid, failure=str(exc), tool_revision=tool_revision, + pull_request_template=pull_request_template, ) client.close_pull_request( repository=full_name, pull_request=existing_pr @@ -528,6 +538,7 @@ def _run_repository( checkout=checkout, allow_dirty_pr=allow_dirty_pr, tool_revision=tool_revision, + pull_request_template=pull_request_template, ) if not evaluation.changes: existing_pr = ( @@ -636,6 +647,7 @@ def _run_repository( head_oid=existing_pr.expected_head_oid, failure=str(exc), tool_revision=tool_revision, + pull_request_template=pull_request_template, ) client.close_pull_request( repository=full_name, pull_request=existing_pr @@ -659,6 +671,7 @@ def _run_repository( changes=evaluation.changes, allow_dirty_pr=allow_dirty_pr, tool_revision=tool_revision, + pull_request_template=pull_request_template, ) if _pull_request_body_changed( existing_pr, @@ -666,6 +679,7 @@ def _run_repository( changes=evaluation.changes, head_oid=existing_pr.expected_head_oid, tool_revision=tool_revision, + pull_request_template=pull_request_template, ): client.update_pull_request( repository=full_name, @@ -674,6 +688,7 @@ def _run_repository( changes=evaluation.changes, head_oid=existing_pr.expected_head_oid, tool_revision=tool_revision, + pull_request_template=pull_request_template, ) return RepositoryOutcome( repository, @@ -711,6 +726,7 @@ def _run_repository( head_oid=head_oid, draft=pre_commit_failure is not None, tool_revision=tool_revision, + pull_request_template=pull_request_template, ) if pre_commit_failure is not None: _comment_dirty_pull_request( @@ -736,6 +752,7 @@ def _run_repository( changes=applied.changes, head_oid=head_oid, tool_revision=tool_revision, + pull_request_template=pull_request_template, ) if pre_commit_failure is not None: _mark_dirty_pull_request( @@ -823,6 +840,7 @@ def _pull_request_body_changed( changes: tuple[Change, ...], head_oid: str, tool_revision: str, + pull_request_template: str | None = None, ) -> bool: """Return whether the generated explanation differs from the PR body.""" @@ -832,6 +850,7 @@ def _pull_request_body_changed( changes, head_oid=head_oid, tool_revision=tool_revision, + pull_request_template=pull_request_template, ) @@ -866,6 +885,7 @@ def _recreate_repository( checkout: Path, allow_dirty_pr: bool = False, tool_revision: str | None = None, + pull_request_template: str | None = None, ) -> RepositoryOutcome: """Rebuild an existing policy branch from the freshly synced default branch.""" @@ -890,6 +910,7 @@ def _recreate_repository( existing_pr=existing_pr, allow_dirty_pr=allow_dirty_pr, tool_revision=tool_revision, + pull_request_template=pull_request_template, ) @@ -905,6 +926,7 @@ def _recreate_existing_pull_request( changes: tuple[Change, ...] | None = None, allow_dirty_pr: bool = False, tool_revision: str | None = None, + pull_request_template: str | None = None, ) -> RepositoryOutcome: """Rebuild one known policy PR from the freshly synchronized default branch.""" @@ -932,6 +954,7 @@ def _recreate_existing_pull_request( changes=body_changes, head_oid=existing_pr.expected_head_oid, tool_revision=tool_revision, + pull_request_template=pull_request_template, ): client.update_pull_request( repository=full_name, @@ -940,6 +963,7 @@ def _recreate_existing_pull_request( changes=body_changes, head_oid=existing_pr.expected_head_oid, tool_revision=tool_revision, + pull_request_template=pull_request_template, ) return RepositoryOutcome( repository, @@ -965,6 +989,7 @@ def _recreate_existing_pull_request( changes=applied.changes, head_oid=head_oid, tool_revision=tool_revision, + pull_request_template=pull_request_template, ) if pre_commit_failure is not None: _mark_dirty_pull_request( diff --git a/repo_policy_sync/tests/test_cli.py b/repo_policy_sync/tests/test_cli.py index 74cdaa9..2eac3f4 100644 --- a/repo_policy_sync/tests/test_cli.py +++ b/repo_policy_sync/tests/test_cli.py @@ -41,6 +41,22 @@ def _empty_report() -> RunReport: ) +def _valid_template() -> str: + return "\n".join( + ( + "custom", + "{{ policy_id }}", + "{{ policy_description }}", + "{{ policy_trigger }}", + "{{ changes }}", + "{{ tool_revision }}", + "{{ failure_section }}", + "{{ policy_marker }}", + "{{ policy_head_marker }}", + ) + ) + + @pytest.mark.parametrize( "argv, message", [ @@ -104,6 +120,38 @@ def test_default_output_is_a_table(monkeypatch, capsys) -> None: assert "📋 Policy evaluations" in captured.out +def test_cli_template_override_is_loaded_and_passed_to_runner( + monkeypatch, tmp_path: Path +) -> None: + template = _valid_template() + template_path = tmp_path / "custom-pull-request.md" + template_path.write_text(template, encoding="utf-8") + observed = {} + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cli, "load_policies", lambda _: ()) + monkeypatch.setattr( + cli, + "run_policies", + lambda **kwargs: observed.update(kwargs) or _empty_report(), + ) + + assert ( + cli.main( + ( + "plan", + "--org", + "eclipse-score", + "--pull-request-template", + template_path.name, + "--quiet", + ) + ) + == 0 + ) + + assert observed["pull_request_template"] == template + + def test_all_reports_can_be_written_from_one_run( monkeypatch, tmp_path: Path, capsys ) -> None: diff --git a/repo_policy_sync/tests/test_config.py b/repo_policy_sync/tests/test_config.py index 158f912..a96e198 100644 --- a/repo_policy_sync/tests/test_config.py +++ b/repo_policy_sync/tests/test_config.py @@ -34,6 +34,7 @@ def test_load_config_resolves_policy_directories_relative_to_config( allow_dirty_pr = true quiet = true cache_dir = ".cache/repo-sync" +pull_request_template = "templates/custom-pull-request.md" sync_workers = 2 policy_workers = 3 """, @@ -51,6 +52,9 @@ def test_load_config_resolves_policy_directories_relative_to_config( assert config.allow_dirty_pr is True assert config.quiet is True assert config.cache_directory == fake_repo / ".cache/repo-sync" + assert ( + config.pull_request_template == fake_repo / "templates/custom-pull-request.md" + ) assert config.sync_workers == 2 assert config.policy_workers == 3 diff --git a/repo_policy_sync/tests/test_github.py b/repo_policy_sync/tests/test_github.py index c453919..5d6ba8b 100644 --- a/repo_policy_sync/tests/test_github.py +++ b/repo_policy_sync/tests/test_github.py @@ -24,9 +24,14 @@ PullRequest, _pull_request_body, _tool_revision, + load_pull_request_template, policy_branches, ) -from repo_policy_sync.src.errors import CommandError, redact_sensitive_text +from repo_policy_sync.src.errors import ( + CommandError, + RepoPolicySyncError, + redact_sensitive_text, +) from repo_policy_sync.src.models import ( BazelCondition, Change, @@ -37,6 +42,22 @@ from repo_policy_sync.src.policy import BUNDLED_POLICY_DIRECTORY, load_policy +def _custom_pull_request_template() -> str: + return "\n".join( + ( + "custom", + "{{ policy_id }}", + "{{ policy_description }}", + "{{ policy_trigger }}", + "{{ changes }}", + "{{ tool_revision }}", + "{{ failure_section }}", + "{{ policy_marker }}", + "{{ policy_head_marker }}", + ) + ) + + def test_commit_stages_deleted_policy_files(monkeypatch, tmp_path: Path) -> None: commands: list[list[str]] = [] @@ -459,10 +480,15 @@ def run(command: list[str]) -> str: changes=(), head_oid="a" * 40, tool_revision="test-revision", + pull_request_template=_custom_pull_request_template(), ) assert pull_request.url == "https://github.example/owner/repo/pull/1" assert pull_request.warnings == () + create_command = next( + command for command in commands if command[:3] == ["gh", "pr", "create"] + ) + assert "custom" in create_command[create_command.index("--body") + 1] assert [ command[4] for command in commands @@ -697,6 +723,39 @@ def run(*_: object, **__: object) -> None: _tool_revision() +def test_custom_pull_request_template_is_loaded_and_rendered(tmp_path: Path) -> None: + template_path = tmp_path / "pull-request.md" + template_path.write_text(_custom_pull_request_template(), encoding="utf-8") + template = load_pull_request_template(template_path) + policy = Policy("example", "Example", "Description", None, ()) + + body = _pull_request_body( + policy, + (Change(Path(".gitignore"), "add '_build'"),), + head_oid="a" * 40, + tool_revision="test-revision", + pull_request_template=template, + ) + + assert body.startswith("custom\nexample\nDescription\n") + assert "- `.gitignore`: add '_build'" in body + assert "" in body + assert "" in body + + +def test_pull_request_template_requires_supported_placeholders(tmp_path: Path) -> None: + template_path = tmp_path / "invalid.md" + template_path.write_text( + "{{ policy_id }}\n{{ unknown-placeholder }}\n", encoding="utf-8" + ) + + with pytest.raises( + RepoPolicySyncError, + match="missing placeholders: policy_description.*unknown placeholders: unknown-placeholder", + ): + load_pull_request_template(template_path) + + def test_module_policy_pull_request_includes_the_matching_rationale() -> None: policy = load_policy( BUNDLED_POLICY_DIRECTORY / "minimal-bazel-module-declaration" / "policy.yml" @@ -748,6 +807,7 @@ def record(command: list[str]) -> str: monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) policy = Policy("example", "Current title", "Current description", None, ()) + template = _custom_pull_request_template() GitHubCli().update_pull_request( repository="owner/repo", @@ -756,6 +816,7 @@ def record(command: list[str]) -> str: changes=(Change(Path(".gitignore"), "add '_build'"),), head_oid="a" * 40, tool_revision="test-revision", + pull_request_template=template, ) assert commands[0][:7] == [ @@ -767,7 +828,7 @@ def record(command: list[str]) -> str: "-f", "title=Current title", ] - assert "## Policy" in commands[0][-1] + assert commands[0][-1].startswith("body=custom\n") def test_pull_request_template_includes_automation_failure() -> None: