diff --git a/.github/workflows/repo-policy-sync.yml b/.github/workflows/repo-policy-sync.yml new file mode 100644 index 0000000..516cfd9 --- /dev/null +++ b/.github/workflows/repo-policy-sync.yml @@ -0,0 +1,47 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +name: Repository policy sync +on: + workflow_dispatch: +permissions: + contents: read + pull-requests: read +jobs: + policy-sync: + runs-on: ubuntu-24.04 + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout repository + uses: actions/checkout@v7.0.0 + - name: Setup uv + uses: astral-sh/setup-uv@v7 + - name: Generate policy reports + id: sync + continue-on-error: true + run: >- + uv run score-repo-policy-sync --config repo_policy_sync/eclipse-score.toml --json-output repo-policy-sync-report.json --markdown-output "$GITHUB_STEP_SUMMARY" + - name: Upload JSON report + if: always() + uses: actions/upload-artifact@v7 + with: + name: repo-policy-sync-report + path: repo-policy-sync-report.json + if-no-files-found: error + - name: Report policy sync status + if: always() + run: | + if [[ "${{ steps.sync.outcome }}" != "success" ]]; then + echo "Repository policy sync detected drift or failed to complete." >&2 + exit 1 + fi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ac62650..787e94d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,6 +36,16 @@ jobs: # run: | # cd python_basics/integration_tests # bazel test //... - - name: Run cr_checker unit tests + - name: Run Python tests + run: uv run --locked pytest --ignore-glob='bazel-*' + - name: Run Ruff quality checks run: | - uv run pytest cr_checker/tests/ + uv run --locked ruff check . + uv run --locked ruff format --check . + - name: Build the Python wheel + run: uv build --wheel + - name: Smoke-test the installed Repository Policy Sync CLI + run: | + wheel=$(find dist -maxdepth 1 -name '*.whl' -print -quit) + uv run --no-project --with "$wheel" score-copyright --help + uv run --no-project --with "$wheel" score-repo-policy-sync --help diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c5ec0e7..28f6025 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,6 +32,6 @@ repos: hooks: - id: copyright name: Check and fix copyright headers with cr_checker - entry: cr_checker/tool/cr_checker.py --exclusion copyright_exclusions.txt --fix + entry: cr_checker/tool/cr_checker.py --exclusion-file copyright_exclusions.txt --fix language: script minimum_pre_commit_version: 3.2.0 diff --git a/README.md b/README.md index 818a361..b5a0c75 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,10 @@ Before adding a new utility here, check: consumers better than adding it here? If any of these gives you pause, raise it for discussion before merging. + +## Repository Policy Sync + +The [`repo_policy_sync`](repo_policy_sync/README.md) component evaluates and +optionally remediates repository policies across a GitHub organization. Its +supported entry point is `score-repo-policy-sync`; start with plan mode and +use apply mode only after reviewing the generated changes. diff --git a/cr_checker/tests/test_cr_checker.py b/cr_checker/tests/test_cr_checker.py index 7c4214a..c9c64bf 100644 --- a/cr_checker/tests/test_cr_checker.py +++ b/cr_checker/tests/test_cr_checker.py @@ -72,7 +72,7 @@ def test_detect_shebang_offset_counts_trailing_newlines(tmp_path): "rst", ] ) -def prepare_test_with_header(request: SubRequest, tmp_path: PosixPath) -> tuple: +def prepare_test_with_header(request, tmp_path: Path) -> tuple: extension = request.param test_file = tmp_path / ("file." + extension) header_template = load_template(extension) @@ -103,11 +103,10 @@ def prepare_test_with_header(request: SubRequest, tmp_path: PosixPath) -> tuple: "rst", ] ) -def prepare_test_no_header(request: SubRequest, tmp_path: PosixPath) -> tuple: +def prepare_test_no_header(request, tmp_path: Path) -> tuple: extension = request.param test_file = tmp_path / ("file." + extension) header_template = load_template(extension) - current_year = datetime.now().year test_file.write_text( "some content\n", encoding="utf-8", diff --git a/pyproject.toml b/pyproject.toml index ce807fb..4cc43ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,5 +6,27 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "bazel-runfiles==1.3.0", + "pre-commit>=4.0.0", + "pydantic>=2.0", + "pyyaml>=6.0", +] + +[project.scripts] +score-copyright = "cr_checker.tool.cr_checker:main" +score-repo-policy-sync = "repo_policy_sync.cli:main" + +[dependency-groups] +dev = [ "pytest>=9.1.1", + "ruff==0.15.10", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = [ + "cr_checker", + "repo_policy_sync", ] diff --git a/repo_policy_sync/README.md b/repo_policy_sync/README.md new file mode 100644 index 0000000..d2af8ea --- /dev/null +++ b/repo_policy_sync/README.md @@ -0,0 +1,167 @@ + + +# SCORE Repository Policy Sync + +SCORE Repository Policy Sync continuously evaluates declarative repository +policies across a GitHub organization. It checks each repository's current +default branch and, when requested, opens or updates one reviewable pull +request per policy and repository. + +The default mode is safe to use in CI: it only reports drift and makes no +remote changes. Apply mode is deliberately explicit and preserves policy PR +ownership so repeated runs update the same proposal rather than creating +duplicates. When an existing policy PR is already correct but conflicts with +its target branch, apply mode automatically rebuilds it from the current +default branch; body-only changes update the PR text without rebuilding its +branch. + +## Quick start + +Requirements: Python 3.12+, [uv](https://docs.astral.sh/uv), Git, and an +authenticated [GitHub CLI](https://cli.github.com/). + +```bash +uv sync +gh auth login + +# Plan with local policies from ./policies, when present, and the bundled SCORE +# policies without changing remote repositories. +uv run score-repo-policy-sync plan --org eclipse-score + +# Add another local policy directory when needed. +uv run score-repo-policy-sync plan --org eclipse-score \ + --policy-dir shared-policies + +# Apply: create or update policy-owned pull requests. +uv run score-repo-policy-sync apply --org eclipse-score + +# Collect matching repository files for policy design and fixture review. +uv run score-repo-policy-sync collect-samples \ + --org eclipse-score \ + --policy score-docs-workflow-alignment \ + --output /tmp/score-policy-samples +``` + +Pre-commit is run again when the first run applies formatting fixes. If the +second run is clean, those fixes are included in the normal pull request. If +pre-commit still fails but the changes should remain reviewable, opt in to a +draft pull request. The failure is added as a PR comment: + +```bash +uv run score-repo-policy-sync apply --org eclipse-score --allow-dirty-pr +``` + +Restrict a run with repeatable `--policy NAME` and `--repo NAME` flags. The +`--policy` option selects local policies; bundled SCORE policies are included +by default and can be removed with `--exclude-bundled-policy NAME`. Policy +names are directory names below the selected policy directories. Use repeated +`--policy-dir PATH` options to combine local policy directories: + +```bash +uv run score-repo-policy-sync plan \ + --org eclipse-score \ + --repo reference_integration \ + --policy-dir repo_policy_sync/policies \ + --policy minimum-bazel-version + +uv run score-repo-policy-sync plan \ + --org etas \ + --repo reference_integration +``` + +To exclude a bundled policy for a repository or rollout: + +```bash +uv run score-repo-policy-sync plan \ + --org etas \ + --exclude-bundled-policy minimum-bazel-version +``` + +Policy options can be kept in the optional `score-repo-policy-sync.toml` file. +Explicit CLI values override the file; see the +[configuration reference](docs/reference/configuration.md). + +The CLI always prints a compact table to standard output. Pass +`--json-output PATH` and/or `--markdown-output PATH` to write additional +versioned JSON and Markdown reports during the same policy run. Markdown is +suited for pull requests, issues, and wikis. Its cells use `✅` for compliant, +`❌` for required changes, `N/A` for policies that do not apply, and +`⚠️`/`⏭️` for errors or skipped evaluations. Open, merged, and automatically +closed policy pull requests are shown as linked GitHub-logo badges in the +affected cells; change and error details are kept in a collapsible section. +Plan mode exits `1` +when policy drift is found, `0` when no policy drift is found, and `2` for +input or execution errors. Apply mode exits `0` after successful remediation. + +## Operational model + +Checkouts are cached under +`$XDG_CACHE_HOME/repo-cache//` or +`~/.cache/repo-cache//`. The generic cache can be shared +with other repository tools. Checkouts are disposable: each run refreshes the +selected repositories to their current default branches before evaluation. +Archived repositories are excluded. Use `--cache-dir PATH` in CI to choose a +workspace-local cache and `--sync-workers N` to control concurrent checkout +synchronization. Policy evaluation and apply work, including policy follow-up +commands such as Bazel, run across independent repositories with +`--policy-workers N`. + +To rebuild a policy PR from the current default branch, use the guarded +recreate operation with exactly one repository and policy: + +```bash +uv run score-repo-policy-sync apply --org eclipse-score --repo reference_integration \ + --policy minimum-bazel-version --recreate +``` + +## Documentation + +The [documentation index](docs/README.md) is organized using the four Diataxis +quadrants: + +- **Tutorials:** [create your first policy](docs/tutorials/first-policy.md). +- **How-to guides:** [run a policy](docs/how-to/run-a-policy.md). +- **Reference:** [CLI](docs/reference/cli.md) and + [policy format](docs/reference/policy-format.md) plus the + [bundled policy overview](policies/README.md). +- **Explanation:** [architecture](docs/explanation/architecture.md), + [execution model](docs/explanation/execution-model.md), and + [pull request safety](docs/explanation/pull-request-safety.md). + +## First-version interface + +The supported executable is `score-repo-policy-sync`. Policy IDs are the +directory names containing each `policy.yml`, and policy-owned branches use +the `repo-policy-sync/` naming scheme. The first version does not +provide command aliases or historical policy-ID compatibility; update callers +to the supported command and current policy IDs before rollout. + +## First-version change summary + +The first version provides declarative bundled and local policies, fixture- +tested idempotent operations, safe plan mode, explicit apply mode, and +policy-owned pull requests with terminal, JSON, and Markdown reports. It +supports the documented GitHub organization workflow, bounded checkout and +policy concurrency, and recovery from stale or conflicting policy branches. + +Compatibility notes: + +- Callers must use `score-repo-policy-sync`, `--policy-dir`, the current policy + IDs, and `repo-policy-sync/` branches. Legacy command aliases, + `--policy-directory`, and historical policy IDs are not supported. +- Renaming a policy changes its branch and pull-request identity. Existing + policy branches or pull requests must be handled before adopting the new ID. +- The first version intentionally does not provide dynamic operation plugins, + persistent result storage, generalized retries/rate-limit handling, or + non-GitHub providers. diff --git a/repo_policy_sync/__init__.py b/repo_policy_sync/__init__.py new file mode 100644 index 0000000..f0f279d --- /dev/null +++ b/repo_policy_sync/__init__.py @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Organization-wide repository policy synchronization.""" diff --git a/repo_policy_sync/bazel.py b/repo_policy_sync/bazel.py new file mode 100644 index 0000000..09a4853 --- /dev/null +++ b/repo_policy_sync/bazel.py @@ -0,0 +1,128 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Small helpers for parsing and comparing bzlmod versions.""" + +from __future__ import annotations + +import re + +from .models import BazelDependencyCondition + +BazelVersion = tuple[int, int, int] + +_VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") +_CONDITION = re.compile( + r"\A\s*([A-Za-z0-9_][A-Za-z0-9_.-]*)\s*(==|!=|<=|>=|<|>)\s*" + r"((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))\s*\Z" +) + + +def parse_bazel_version(value: str) -> BazelVersion | None: + """Parse a strict major.minor.patch version.""" + + match = _VERSION.fullmatch(value) + return tuple(int(component) for component in match.groups()) if match else None + + +def starlark_call_ranges(text: str, function_name: str) -> tuple[tuple[int, int], ...]: + """Return body ranges for calls outside comments and strings.""" + + # Masking non-code text preserves the original offsets, so callers can + # inspect the original source and still apply precise replacements. + masked = _mask_starlark(text) + pattern = re.compile(rf"(? str: + # Comments and strings can contain text that looks like a real call. Replace + # them with spaces while retaining newlines and character positions for the + # offset calculations in starlark_call_ranges. + masked = list(text) + index = 0 + while index < len(text): + if text[index] == "#": + while index < len(text) and text[index] not in "\r\n": + masked[index] = " " + index += 1 + continue + if text[index] not in "'\"": + index += 1 + continue + quote = text[index] + delimiter = quote * 3 if text.startswith(quote * 3, index) else quote + for offset in range(len(delimiter)): + masked[index + offset] = " " + index += len(delimiter) + escaped = False + while index < len(text): + character = text[index] + if character not in "\r\n": + masked[index] = " " + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif text.startswith(delimiter, index): + for offset in range(len(delimiter)): + masked[index + offset] = " " + index += len(delimiter) + break + index += 1 + return "".join(masked) + + +def parse_bazel_dependency_condition(value: str) -> BazelDependencyCondition | None: + """Parse ``module OP major.minor.patch`` condition syntax.""" + + match = _CONDITION.fullmatch(value) + if match is None: + return None + version = parse_bazel_version(match.group(3)) + assert version is not None + return BazelDependencyCondition(match.group(1), match.group(2), version) + + +def matches_bazel_dependency_condition( + actual: BazelVersion, condition: BazelDependencyCondition +) -> bool: + """Compare one parsed dependency version with a policy condition.""" + + if condition.operator == "==": + return actual == condition.version + if condition.operator == "!=": + return actual != condition.version + if condition.operator == "<": + return actual < condition.version + if condition.operator == "<=": + return actual <= condition.version + if condition.operator == ">": + return actual > condition.version + if condition.operator == ">=": + return actual >= condition.version + raise ValueError(f"unsupported Bazel version operator: {condition.operator}") diff --git a/repo_policy_sync/cache.py b/repo_policy_sync/cache.py new file mode 100644 index 0000000..e7b15b0 --- /dev/null +++ b/repo_policy_sync/cache.py @@ -0,0 +1,27 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Locations for persistent, disposable repository checkouts.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def default_checkout_cache_directory() -> Path: + """Return the standard user cache location without creating it.""" + + xdg_cache_home = os.environ.get("XDG_CACHE_HOME") + cache_home = Path(xdg_cache_home) if xdg_cache_home else Path.home() / ".cache" + return cache_home / "repo-cache" diff --git a/repo_policy_sync/cli.py b/repo_policy_sync/cli.py new file mode 100644 index 0000000..22a2ae5 --- /dev/null +++ b/repo_policy_sync/cli.py @@ -0,0 +1,388 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Command-line entry point for SCORE Repository Policy Sync.""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence +from pathlib import Path + +from .cache import default_checkout_cache_directory +from .config import load_config +from .errors import PolicyError, RepoPolicySyncError +from .github import GitHubCli +from .policy import ( + BUNDLED_POLICY_DIRECTORY, + DEFAULT_POLICY_DIRECTORY, + discover_policy_paths, + load_policies, + resolve_policy_names, +) +from .reporting import render_json, render_markdown, render_table +from .runner import DEFAULT_POLICY_WORKERS, DEFAULT_SYNC_WORKERS, run_policies +from .samples import collect_samples, render_sample_collection + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="score-repo-policy-sync", + description="Evaluate and remediate repository policies across a GitHub organization.", + epilog=( + "All policy options except command-specific output options may also be " + "set in the TOML configuration. " + "Explicit command-line values override configuration values." + ), + ) + commands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") + plan = commands.add_parser( + "plan", help="Evaluate policies without changing repositories." + ) + _add_common_arguments(plan, reports=True) + apply = commands.add_parser( + "apply", help="Apply policies and manage pull requests." + ) + _add_common_arguments(apply, reports=True) + apply.add_argument( + "--recreate", + action=argparse.BooleanOptionalAction, + default=None, + help="Recreate one existing policy-owned pull request from the current default branch.", + ) + apply.add_argument( + "--allow-dirty-pr", + action=argparse.BooleanOptionalAction, + default=None, + help=( + "After the automatic formatting-fix retry, create a draft pull request " + "when pre-commit still fails and comment with the failure." + ), + ) + collect = commands.add_parser( + "collect-samples", + help="Collect policy-matching repository files without changing repositories.", + ) + _add_common_arguments(collect) + collect.add_argument( + "--output", + type=Path, + required=True, + metavar="DIRECTORY", + help="Empty directory in which to write collected samples and inventory.json.", + ) + return parser + + +def _add_common_arguments( + parser: argparse.ArgumentParser, *, reports: bool = False +) -> None: + typical = parser.add_argument_group("Typical") + rare = parser.add_argument_group("Rare") + debugging = parser.add_argument_group("Debugging only") + typical.add_argument( + "--org", + help="GitHub organization name (also available in the TOML configuration).", + ) + typical.add_argument( + "--policy", + action="append", + metavar="NAME", + help="Select one policy by name. Repeat to select policies; defaults to all available policies.", + ) + typical.add_argument( + "--repo", + action="append", + default=None, + help="Exact repository name to include. Repeat to include more repositories.", + ) + rare.add_argument( + "--config", + type=Path, + help="TOML configuration file (default: score-repo-policy-sync.toml if present).", + ) + if reports: + rare.add_argument( + "--json-output", + type=Path, + metavar="PATH", + help="Also write the JSON report to PATH.", + ) + rare.add_argument( + "--markdown-output", + type=Path, + metavar="PATH", + help="Also write the Markdown report to PATH.", + ) + rare.add_argument( + "--policy-dir", + dest="policy_dir", + type=Path, + action="append", + help="Local policy directory; repeat to combine directories (default: ./policies if present).", + ) + rare.add_argument( + "--exclude-bundled-policy", + action="append", + metavar="NAME", + help="Exclude one bundled SCORE policy by name. Repeat to exclude policies.", + ) + rare.add_argument( + "--quiet", + action=argparse.BooleanOptionalAction, + default=None, + help="Suppress progress messages on standard error.", + ) + debugging.add_argument( + "--cache-dir", + type=Path, + default=None, + help="Persistent directory for disposable repository checkouts.", + ) + debugging.add_argument( + "--sync-workers", + type=int, + default=None, + help=( + "Number of repository checkouts to synchronize concurrently " + f"(default: {DEFAULT_SYNC_WORKERS}, the available CPU count)." + ), + ) + debugging.add_argument( + "--policy-workers", + type=int, + default=None, + help=( + "Number of repositories to evaluate or apply per policy concurrently " + f"(default: {DEFAULT_POLICY_WORKERS}, the available CPU count)." + ), + ) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = create_parser() + args = parser.parse_args(argv) + try: + config = load_config(args.config) + org = args.org if args.org is not None else config.org + if not org: + parser.error("--org is required unless it is set in the TOML configuration") + policy_names = tuple( + args.policy if args.policy is not None else (config.policies or ()) + ) + repository_names = tuple( + args.repo if args.repo is not None else (config.repositories or ()) + ) + applying = args.command == "apply" + recreate = args.recreate if applying and args.recreate is not None else False + allow_dirty_pr = ( + args.allow_dirty_pr + if applying and args.allow_dirty_pr is not None + else False + ) + if not applying and (config.recreate or config.allow_dirty_pr): + parser.error( + "configuration options recreate and allow_dirty_pr require the apply command" + ) + if applying: + recreate = ( + args.recreate + if args.recreate is not None + else (config.recreate or False) + ) + allow_dirty_pr = ( + args.allow_dirty_pr + if args.allow_dirty_pr is not None + else (config.allow_dirty_pr or False) + ) + quiet = args.quiet if args.quiet is not None else (config.quiet or False) + cache_directory = ( + args.cache_dir + if args.cache_dir is not None + else (config.cache_directory or default_checkout_cache_directory()) + ) + sync_workers = ( + args.sync_workers + if args.sync_workers is not None + else (config.sync_workers or DEFAULT_SYNC_WORKERS) + ) + policy_workers = ( + args.policy_workers + if args.policy_workers is not None + else (config.policy_workers or DEFAULT_POLICY_WORKERS) + ) + excluded_bundled_names = tuple( + args.exclude_bundled_policy + if args.exclude_bundled_policy is not None + else config.exclude_bundled_policies + ) + if recreate: + if len(repository_names) != 1: + parser.error("--recreate requires exactly one --repo") + if len(policy_names) != 1: + parser.error("--recreate requires exactly one --policy") + if not quiet: + print("Loading policies...", file=sys.stderr, flush=True) + policy_directories = ( + tuple(dict.fromkeys(args.policy_dir)) + if args.policy_dir is not None + else config.policy_directories + ) + if recreate: + policy_paths = _resolve_recreate_policy_paths( + policy_names, + policy_directories, + ) + else: + if policy_names: + local_policy_paths = resolve_policy_names( + policy_names, + policy_directories + if policy_directories is not None + else (DEFAULT_POLICY_DIRECTORY,), + ) + elif policy_directories is not None: + local_policy_paths = tuple( + path + for policy_directory in dict.fromkeys(policy_directories) + for path in discover_policy_paths(policy_directory) + ) + elif DEFAULT_POLICY_DIRECTORY.is_dir(): + local_policy_paths = discover_policy_paths(DEFAULT_POLICY_DIRECTORY) + else: + local_policy_paths = () + bundled_policy_paths = discover_policy_paths(BUNDLED_POLICY_DIRECTORY) + local_policy_path_keys = {path.resolve() for path in local_policy_paths} + bundled_policy_paths = tuple( + path + for path in bundled_policy_paths + if path.resolve() not in local_policy_path_keys + ) + if excluded_bundled_names: + excluded_bundled_paths = set( + resolve_policy_names( + excluded_bundled_names, BUNDLED_POLICY_DIRECTORY + ) + ) + bundled_policy_paths = tuple( + path + for path in bundled_policy_paths + if path not in excluded_bundled_paths + ) + policy_paths = local_policy_paths + bundled_policy_paths + policies = load_policies(policy_paths) + if args.command == "collect-samples": + sample_report = collect_samples( + client=GitHubCli(), + org=org, + policies=policies, + repository_names=repository_names, + checkout_cache_directory=cache_directory, + output_directory=args.output, + sync_workers=sync_workers, + progress=_discard_progress if quiet else _write_progress, + ) + print(render_sample_collection(sample_report)) + return 2 if sample_report.sync_failures else 0 + + report = run_policies( + client=GitHubCli(), + org=org, + policies=policies, + repository_names=repository_names, + checkout_cache_directory=cache_directory, + apply=applying, + recreate=recreate, + allow_dirty_pr=allow_dirty_pr, + sync_workers=sync_workers, + policy_workers=policy_workers, + include_pull_request_status=( + args.json_output is not None or args.markdown_output is not None + ), + progress=_discard_progress if quiet else _write_progress, + ) + except RepoPolicySyncError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + json_output = render_json(report) if args.json_output is not None else None + markdown_output = ( + render_markdown(report) if args.markdown_output is not None else None + ) + output = render_table(report) + + try: + if args.json_output is not None: + _write_report(args.json_output, json_output) + if args.markdown_output is not None: + _write_report(args.markdown_output, markdown_output) + except RepoPolicySyncError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + print(output) + if report.summary.sync_failures or report.summary.evaluation_failures: + return 2 + return 1 if report.summary.drifted and not applying else 0 + + +def _write_progress(message: str) -> None: + print(message, file=sys.stderr, flush=True) + + +def _discard_progress(_: str) -> None: + pass + + +def _resolve_recreate_policy_paths( + policy_names: tuple[str, ...], + policy_directories: tuple[Path, ...] | None, +) -> tuple[Path, ...]: + """Resolve the one policy targeted by recreate, including bundled policies.""" + + configured_directories = ( + tuple(dict.fromkeys(policy_directories)) + if policy_directories is not None + else ((DEFAULT_POLICY_DIRECTORY,) if DEFAULT_POLICY_DIRECTORY.is_dir() else ()) + ) + # Recreate is deliberately restricted to one policy. Resolve its expected + # path directly so an unrelated malformed policy cannot block the request. + candidates = tuple( + directory / name / "policy.yml" + for directory in configured_directories + for name in policy_names + if (directory / name / "policy.yml").is_file() + ) + if candidates: + if len(candidates) > 1: + raise PolicyError(f"policy ID is not unique: {policy_names[0]}") + return candidates + + bundled_path = BUNDLED_POLICY_DIRECTORY / policy_names[0] / "policy.yml" + if bundled_path.is_file(): + return (bundled_path,) + raise PolicyError(f"unknown policy name(s): {', '.join(policy_names)}") + + +def _write_report(path: Path, output: str | None) -> None: + if output is None: # pragma: no cover - guarded by the callers above + raise RepoPolicySyncError(f"no report was rendered for output path {path}") + try: + path.write_text(output + "\n", encoding="utf-8") + except OSError as exc: + raise RepoPolicySyncError(f"could not write report {path}: {exc}") from exc + + +if __name__ == "__main__": # pragma: no cover - exercised by the console script + raise SystemExit(main()) diff --git a/repo_policy_sync/config.py b/repo_policy_sync/config.py new file mode 100644 index 0000000..7246c4e --- /dev/null +++ b/repo_policy_sync/config.py @@ -0,0 +1,206 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""TOML configuration for Repository Policy Sync.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import tomllib +from typing import Any + +from .errors import RepoPolicySyncError + +DEFAULT_CONFIG_FILE = Path("score-repo-policy-sync.toml") +CONFIG_SECTION = "score-repo-policy-sync" + + +@dataclass(frozen=True) +class PolicySyncConfig: + """Command settings loaded from TOML.""" + + org: str | None = None + policies: tuple[str, ...] | None = None + repositories: tuple[str, ...] | None = None + policy_directories: tuple[Path, ...] | None = None + exclude_bundled_policies: tuple[str, ...] = () + recreate: bool | None = None + allow_dirty_pr: bool | None = None + quiet: bool | None = None + cache_directory: Path | None = None + sync_workers: int | None = None + policy_workers: int | None = None + + +def load_config(path: Path | None = None) -> PolicySyncConfig: + """Load the optional TOML configuration file. + + When no path is supplied, ``score-repo-policy-sync.toml`` is loaded if it + exists in the current working directory. Paths in the file are resolved + relative to that file. + """ + + config_path = path or DEFAULT_CONFIG_FILE + if not config_path.is_file(): + if path is None: + return PolicySyncConfig() + raise RepoPolicySyncError(f"configuration file does not exist: {config_path}") + try: + with config_path.open("rb") as stream: + raw = tomllib.load(stream) + except OSError as exc: + raise RepoPolicySyncError( + f"could not read configuration {config_path}: {exc}" + ) from exc + except UnicodeError as exc: + raise RepoPolicySyncError( + f"could not decode configuration {config_path} as UTF-8: {exc}" + ) from exc + except tomllib.TOMLDecodeError as exc: + raise RepoPolicySyncError( + f"invalid TOML in configuration {config_path}: {exc}" + ) from exc + + unexpected_sections = set(raw) - {CONFIG_SECTION} + if unexpected_sections: + names = ", ".join(sorted(unexpected_sections)) + raise RepoPolicySyncError( + f"configuration {config_path}: unexpected sections: {names}" + ) + section = raw.get(CONFIG_SECTION, {}) + if not isinstance(section, dict): + raise RepoPolicySyncError( + f"configuration {config_path}: [{CONFIG_SECTION}] must be a table" + ) + unexpected = set(section) - { + "org", + "policies", + "repos", + "policy_dirs", + "exclude_bundled_policies", + "recreate", + "allow_dirty_pr", + "quiet", + "cache_dir", + "sync_workers", + "policy_workers", + } + if unexpected: + names = ", ".join(sorted(unexpected)) + raise RepoPolicySyncError( + f"configuration {config_path}: unexpected fields: {names}" + ) + + org = _optional_string(section, "org", config_path) + policies = _optional_string_list(section, "policies", config_path) + repositories = _optional_string_list(section, "repos", config_path) + + policy_directories = None + if "policy_dirs" in section: + policy_directories = tuple( + _string_list(section["policy_dirs"], "policy_dirs", config_path) + ) + policy_directories = tuple( + _resolve_path(config_path, directory) for directory in policy_directories + ) + exclude_bundled_policies = tuple( + _string_list( + section.get("exclude_bundled_policies", []), + "exclude_bundled_policies", + config_path, + ) + ) + recreate = _optional_bool(section, "recreate", config_path) + allow_dirty_pr = _optional_bool(section, "allow_dirty_pr", config_path) + quiet = _optional_bool(section, "quiet", config_path) + cache_directory = None + if "cache_dir" in section: + cache_directory = _resolve_path( + config_path, + _string_value(section["cache_dir"], "cache_dir", config_path), + ) + sync_workers = _optional_positive_int(section, "sync_workers", config_path) + policy_workers = _optional_positive_int(section, "policy_workers", config_path) + return PolicySyncConfig( + org=org, + policies=policies, + repositories=repositories, + policy_directories=policy_directories, + exclude_bundled_policies=exclude_bundled_policies, + recreate=recreate, + allow_dirty_pr=allow_dirty_pr, + quiet=quiet, + cache_directory=cache_directory, + sync_workers=sync_workers, + policy_workers=policy_workers, + ) + + +def _optional_string(section: dict[str, Any], field: str, source: Path) -> str | None: + if field not in section: + return None + return _string_value(section[field], field, source) + + +def _string_value(raw: Any, field: str, source: Path) -> str: + if not isinstance(raw, str) or not raw.strip(): + raise RepoPolicySyncError( + f"configuration {source}: {field} must be a non-empty string" + ) + return raw + + +def _optional_string_list( + section: dict[str, Any], field: str, source: Path +) -> tuple[str, ...] | None: + if field not in section: + return None + return tuple(_string_list(section[field], field, source)) + + +def _string_list(raw: Any, field: str, source: Path) -> tuple[str, ...]: + if not isinstance(raw, list) or any( + not isinstance(item, str) or not item.strip() for item in raw + ): + raise RepoPolicySyncError( + f"configuration {source}: {field} must be a list of non-empty strings" + ) + return tuple(raw) + + +def _optional_bool(section: dict[str, Any], field: str, source: Path) -> bool | None: + if field not in section: + return None + raw = section[field] + if not isinstance(raw, bool): + raise RepoPolicySyncError(f"configuration {source}: {field} must be a boolean") + return raw + + +def _optional_positive_int( + section: dict[str, Any], field: str, source: Path +) -> int | None: + if field not in section: + return None + raw = section[field] + if isinstance(raw, bool) or not isinstance(raw, int) or raw < 1: + raise RepoPolicySyncError( + f"configuration {source}: {field} must be a positive integer" + ) + return raw + + +def _resolve_path(config_path: Path, value: str) -> Path: + path = Path(value) + return path if path.is_absolute() else config_path.parent / path diff --git a/repo_policy_sync/docs/README.md b/repo_policy_sync/docs/README.md new file mode 100644 index 0000000..7b449a9 --- /dev/null +++ b/repo_policy_sync/docs/README.md @@ -0,0 +1,48 @@ + + +# Documentation + +Repository Policy Sync uses the four Diataxis documentation quadrants. Choose +the category that matches what you need now. + +## Tutorials + +Learning-oriented lessons for a first successful result. + +- [Create your first policy](tutorials/first-policy.md) + +## How-to guides + +Task-oriented instructions for an already familiar user. + +- [Run a policy](how-to/run-a-policy.md) + +## Reference + +Complete factual descriptions of supported interfaces. + +- [CLI reference](reference/cli.md) +- [Configuration reference](reference/configuration.md) +- [Policy format reference](reference/policy-format.md) +- [Built-in operations catalogue](../operations/README.md) +- [Bundled policy overview](../policies/README.md) + +## Explanation + +Background, design rationale, boundaries, and safety model. + +- [Architecture](explanation/architecture.md) +- [Execution model](explanation/execution-model.md) +- [Pull request safety](explanation/pull-request-safety.md) +- [Alternatives and rationale](explanation/alternatives.md) diff --git a/repo_policy_sync/docs/explanation/alternatives.md b/repo_policy_sync/docs/explanation/alternatives.md new file mode 100644 index 0000000..4bcaa36 --- /dev/null +++ b/repo_policy_sync/docs/explanation/alternatives.md @@ -0,0 +1,86 @@ + + +# Alternatives and rationale + +This guide explains why Repository Policy Sync exists alongside other fleet +maintenance approaches. + +SCORE Repository Policy Sync exists to manage organization-wide repository +policies: desired states that are declarative, repeatable, reviewable, and safe +to reconcile again. It is not intended to be the only way to edit many +repositories. + +## Decision criteria + +A suitable policy tool must support more than cloning repositories and applying +a text change. In particular, it needs to: + +- select repositories efficiently while making the final decision from current + checkout content; +- express an idempotent desired state and explain why a change is needed; +- test a policy against small before/after repository fixtures; +- create one identifiable pull request per policy and repository; and +- update that pull request predictably on later runs without taking over a + user-owned branch. + +## all-repos + +[all-repos](https://github.com/asottile/all-repos) is a mature tool for cloning +a configured set of repositories and applying sweeping changes. Its distributed +`grep` and `sed` commands, custom autofixers, repository discovery, and +parallel execution make it a strong choice for one-off or imperative fleet +maintenance. + +It is not the foundation for Repository Policy Sync because its generic +clone/autofix/push model does not provide this project's policy contract out of +the box: + +- YAML policies with checked-in executable examples; +- live checkout-based compliance evaluation across all selected repositories; +- per-policy applicability conditions and operation rationales; or +- stable policy ownership of branch names and pull-request bodies. + +Building those features as an all-repos autofixer and custom push integration +would retain its configuration and authentication model while duplicating the +core behavior of Repository Policy Sync. That increases the number of control +planes without reducing the policy-specific code. + +Use all-repos when a maintainer needs an ad-hoc, imperative sweep. Use +Repository Policy Sync when the change is an enduring organization policy that +should be stored, tested, and rerun as a policy. + +## GitHub Actions and scripts + +GitHub Actions is appropriate for repository-local enforcement, such as a +format or validation check that runs on every pull request. It is less suitable +for centrally discovering, evaluating, and remediating a changing set of +repositories. A standalone script has the opposite trade-off: it is quick for +a one-off migration but does not naturally preserve policy identity, fixtures, +or pull-request lifecycle. + +Ansible's +[`ansible.builtin.blockinfile`](https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/blockinfile_module.html) +is a useful declarative option when a known file on a known target needs one +managed, marker-delimited text block. It is idempotent and supports check and +diff modes, but is deliberately a file-editing primitive rather than a +repository-policy system. Using it for this problem would still require custom +inventory or repository discovery, checkout evaluation, fixture testing, and +pull-request lifecycle management around the playbook. + +The current design keeps these roles separate: + +- repository-local automation remains in each repository or its GitHub Actions; +- ad-hoc fleet edits can use all-repos or a focused script; and +- Ansible can manage a known, marker-delimited block in a known file; and +- durable cross-repository policy remediation belongs in Repository Policy Sync. diff --git a/repo_policy_sync/docs/explanation/architecture.md b/repo_policy_sync/docs/explanation/architecture.md new file mode 100644 index 0000000..abf35f5 --- /dev/null +++ b/repo_policy_sync/docs/explanation/architecture.md @@ -0,0 +1,82 @@ + + +# Architecture and design + +SCORE Repository Policy Sync separates policy semantics from command-line and +GitHub concerns. +Most behavior can therefore be tested against ordinary temporary directories, +without network access, Git, or `gh`. + +```text +cli.py + ├─ reporting.py table and versioned JSON renderers + └─ runner.py organization-level execution and report assembly + ├─ policy.py YAML → validated Policy objects + ├─ operations/ built-in operation registry and implementations + ├─ engine.py evaluate and apply policies to a checkout + └─ github.py gh and Git process adapter +``` + +`models.py` contains the immutable values exchanged across these layers. +`errors.py` defines expected user-facing errors. `cli.py` is the composition +root: it creates the concrete GitHub client, selects a renderer, and translates +failures into CLI exit codes. + +## Responsibilities + +### Policy loading and operations + +`policy.py` validates policy metadata and conditions. It delegates each +`ensure[].type` to the explicit registry in `operations/`. An operation owns +its YAML validation, compliance check, remediation description, and application. + +The registry is intentionally built in. It makes supported operations visible +and testable without runtime discovery, third-party code loading, or an +extension ABI. Add a new operation by implementing it in `operations/` and +registering it there. + +### Policy engine + +`engine.py` evaluates a policy against one local repository directory. It +checks conditions, gathers the changes that would be made, and applies those +same idempotent operations when requested. It has no GitHub, Git, or +argument-parsing dependency. + +### Orchestration and infrastructure + +`runner.py` discovers repositories, refreshes the selected cached checkouts +in parallel, then coordinates one policy across independent repository +checkouts in parallel through a small `RepositoryClient` protocol. It returns +a structured `RunReport` rather than formatting output. `reporting.py` renders +that report as a terminal table or a versioned JSON document. `github.py` +implements the protocol with the pre-authenticated `gh` CLI and Git. + +## Invariants + +- A refreshed repository checkout is always the source of truth for + applicability and compliance. +- Policy operations use repository-relative paths and are idempotent. +- Each policy maps to one deterministic branch per repository. +- A branch is treated as tool-owned only when its open pull request contains + the policy marker. +- An apply run updates the owned pull request’s title and body from the current + policy before pushing any required commit. + +## Deliberate boundaries + +The current scope does not provide dynamic plugins, retries, rate-limit +handling, or policy result storage. Checkout synchronization and per-policy +repository processing are parallel; each repository checkout remains isolated. +The explicit operation registry is the extension point until a concrete +requirement justifies a more dynamic model. diff --git a/repo_policy_sync/docs/explanation/execution-model.md b/repo_policy_sync/docs/explanation/execution-model.md new file mode 100644 index 0000000..c14215b --- /dev/null +++ b/repo_policy_sync/docs/explanation/execution-model.md @@ -0,0 +1,81 @@ + + +# Execution model and boundaries + +Repository Policy Sync evaluates the bundled SCORE catalogue and, when +present, local policies from `./policies` or the directories selected with +`--policy-dir`. Bundled policies can be excluded with +`--exclude-bundled-policy`. Policy names selected with `--policy` are resolved +from the local policy directories. A policy that needs changes +owns one deterministic branch and one pull request per repository. + +## Lifecycle + +1. Discover organization repositories and load policies. Policy definitions + are discovered as `policy.yml` files in the selected policy directory in + deterministic path order. +2. Refresh each selected repository’s disposable checkout to its current default branch. +3. Evaluate the policy against that checkout. The checkout is the + authority for applicability and compliance. +4. In plan mode, report required changes and make no remote changes. +5. In apply mode, reuse a policy-owned pull request when present, otherwise + create a policy branch, apply the policy, run the configured pre-commit hooks + on the policy-changed paths when the target repository has + `.pre-commit-config.yaml`, commit, push, and open a pull request. If the + first pre-commit run applies formatting fixes, + the changes are staged and pre-commit is run once more before publishing. + The same pre-commit gate runs before rebuilding an existing policy branch. + Existing policy-owned pull requests receive the current title and body. If + pre-commit still fails after the retry, no commit, push, or pull request is + created. With `--allow-dirty-pr`, the changes are committed and pushed + anyway, and the resulting pull request is draft with a comment containing + the remaining pre-commit failure. + If the refreshed default branch is already compliant, apply mode closes an + existing policy-owned pull request after verifying its branch head; plan + mode leaves the pull request open. + + Pre-commit runs use a credential-reduced environment and temporary home + directory. Hooks are still arbitrary repository code, so apply mode requires + trusted target repositories. + +Checkout synchronization and each policy's repository processing run in +parallel. Repositories remain isolated in separate checkouts; use +`--sync-workers` and `--policy-workers` to bound their respective concurrency. + +## Supported behavior + +- `when.bazel` dependency-presence and version conditions, + `when.file_exists`, and `when.file_contains` conditions; +- the explicitly registered `ensure_line`, `ensure_minimum_version`, + `ensure_bazel_dependency`, `ensure_no_such_file`, `replace_regex`, `migrate_devcontainer_json`, + `synchronize_devcontainer_version`, `synchronize_bazel_dependencies`, and + `synchronize_file` operations; +- fixed `automation` and `repo-policy-sync` labels, policy titles, descriptions, and + per-operation rationales; +- terminal-table output, a compact repository-by-policy Markdown matrix, and a + versioned JSON report for automation. + +## Exit status + +| Status | Meaning | +| --- | --- | +| `0` | Plan mode found no policy drift, or apply mode completed without errors. | +| `1` | Plan mode found one or more required changes. | +| `2` | Input, authentication, GitHub, Git, or execution error. | + +## Current boundaries + +- Handling a remote policy branch that has no open policy-owned pull request. +- Retries and rate-limit handling. +- Additional conditions and built-in operations. diff --git a/repo_policy_sync/docs/explanation/pull-request-safety.md b/repo_policy_sync/docs/explanation/pull-request-safety.md new file mode 100644 index 0000000..8252b3f --- /dev/null +++ b/repo_policy_sync/docs/explanation/pull-request-safety.md @@ -0,0 +1,57 @@ + + +# Pull request safety + +Each changed repository receives at most one open pull request for a policy. +The policy directory name is the policy ID and maps to the deterministic branch +`repo-policy-sync/`, so subsequent runs update the same pull +request instead of creating duplicates. + +The first version recognizes only the current policy ID and the +`repo-policy-sync/` branch and marker. Renaming a policy +is a breaking change: update any existing policy branch or pull request before +using the new ID. + +## Ownership and safety + +The PR body contains invisible markers for the policy ID and the branch head +created by Repository Policy Sync. Before reusing a branch, the tool verifies +that its remote head still matches the stored value. A missing marker or a +mismatch fails the run without updating, closing, or otherwise taking over the +pull request. This protects a policy branch that someone has changed manually. + +If the refreshed default branch is compliant while an owned PR remains open, +apply mode closes that PR after verifying the same branch-head marker used for +other owned-branch changes. Plan mode does not close it. A changed or missing +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 +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. + +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 +absent from the repository; existing labels are not modified. On later runs, +an owned PR whose branch already contains the policy changes is left alone when +its generated body is current; a changed template or explanation updates only +the PR text. If GitHub reports that this unchanged policy branch conflicts with +the target branch, the tool rebuilds it from the freshly synchronized default +branch and reapplies the policy. If applying a policy fails, the tool records +the error and closes that PR only after the same branch-head verification +succeeds. diff --git a/repo_policy_sync/docs/how-to/run-a-policy.md b/repo_policy_sync/docs/how-to/run-a-policy.md new file mode 100644 index 0000000..89cccf5 --- /dev/null +++ b/repo_policy_sync/docs/how-to/run-a-policy.md @@ -0,0 +1,172 @@ + + +# How to run a policy + +Use plan mode first. It refreshes disposable local checkouts and reports drift, +but never changes remote repositories: + +```bash +uv run score-repo-policy-sync plan --org eclipse-score +``` + +The bundled SCORE policies are always loaded. In addition, policies are loaded +from `./policies` in the current working directory when that directory exists. +The directory layout and policy format are the same for local and bundled +policies: + +```bash +uv run score-repo-policy-sync plan \ + --org etas +``` + +Use `--policy-dir PATH` when local policies are stored elsewhere. Repeat the +option to combine local policy directories. + +Exclude a bundled SCORE policy with `--exclude-bundled-policy`: + +```bash +uv run score-repo-policy-sync plan \ + --org eclipse-score \ + --repo reference_integration \ + --exclude-bundled-policy minimum-bazel-version +``` + +The exclusion can be repeated. For a persistent setup, use the optional +`score-repo-policy-sync.toml` configuration file: + +```toml +[score-repo-policy-sync] +exclude_bundled_policies = ["minimum-bazel-version"] +``` + +See the [configuration reference](../reference/configuration.md) for local +policy directories and command-line overrides. + +To inspect all repository files selected by a policy's `when` conditions, +collect read-only samples into an empty local directory: + +```bash +uv run score-repo-policy-sync collect-samples \ + --org eclipse-score \ + --policy score-docs-workflow-alignment \ + --output /tmp/score-policy-samples +``` + +The command writes one `before` case per matching repository and an +`inventory.json`; it never creates branches, commits, or pull requests. + +Limit a rollout to selected policy and repository names with repeatable +`--policy` and `--repo` options: + +```bash +uv run score-repo-policy-sync plan \ + --org eclipse-score \ + --repo reference_integration \ + --policy-dir policies \ + --policy minimum-bazel-version +``` + +When the plan is reviewed, apply the same selection to create or update the +policy-owned pull requests: + +```bash +uv run score-repo-policy-sync apply \ + --org eclipse-score \ + --repo reference_integration \ + --policy-dir policies \ + --policy minimum-bazel-version +``` + +## Authentication and permissions + +Authenticate `gh` before running the command. The token must be able to list +the selected organization repositories, read their default branches and +contents, and read pull requests when Markdown pull-request status is +requested. In GitHub Actions, the plan workflow therefore needs at least: + +```yaml +permissions: + contents: read + pull-requests: read +``` + +Apply mode additionally pushes policy branches, edits or creates pull +requests, creates the automation labels when needed, and can comment on a +dirty draft pull request. An approved apply token normally needs: + +```yaml +permissions: + contents: write + pull-requests: write + issues: write +``` + +For private organizations, grant the equivalent organization and repository +read access required by the organization's token policy. Keep apply workflows +manual or otherwise separately protected; the pull-request validation +workflow must use the `plan` command and must not use `apply`. + +Apply mode runs the target repository's configured pre-commit hooks on the +policy-changed paths before publishing changes. Treat apply mode as +trusted-repository execution: +repository hooks can execute arbitrary code. The runner removes the usual +GitHub token and user configuration environment, disables Git prompts, and +uses a temporary home directory, but this is not a sandbox. + +For CI or another programmatic consumer, write the versioned JSON report to a +file while retaining the standard table output: + +```bash +uv run score-repo-policy-sync plan \ + --org eclipse-score \ + --json-output policy-report.json +``` + +Add `--markdown-output report.md` to generate the Markdown report in the same +run. + +On a normal apply run, an existing policy PR whose branch already contains the +desired changes is not rebuilt unnecessarily. A stale generated body is updated +in place. If that unchanged branch has a merge conflict with the repository's +current default branch, the tool automatically recreates it from that default +branch and reapplies the policy. + +When the refreshed default branch is already compliant, apply mode closes an +existing policy-owned PR after verifying that its branch head still matches the +tool's ownership marker. Plan mode leaves the PR open. A changed or missing +marker stops the run without closing the PR so it can be reviewed manually. + +Use `apply --recreate` only to rebuild one existing policy pull request from +the current default branch. It requires exactly one `--repo` and one +`--policy`; see the [CLI reference](../reference/cli.md) for all constraints. + +## Recovering from failures + +Exit status `2` means that authentication, checkout, GitHub, Git, or policy +execution failed. The terminal report and JSON/Markdown details identify the +affected repository and policy; fix that repository or credential issue and +rerun the same selection. A failed checkout or evaluation does not prevent +other selected repositories from being reported. + +Surfaced runtime errors and automation-failure text redact common GitHub token, +bearer, password, secret, and URL-credential forms. Output from policy +`after_apply` commands is captured instead of being printed directly. Keep +credentials out of policy descriptions, rationales, and command arguments as +an additional precaution. + +If an existing policy branch has changed outside the tool, Repository Policy +Sync refuses to update it. Review the branch and pull request manually before +rerunning. If a generated pull request is in conflict, a normal apply rerun +rebuilds it from the current default branch; `apply --recreate` is available +for the explicitly guarded one-repository, one-policy case. diff --git a/repo_policy_sync/docs/reference/cli.md b/repo_policy_sync/docs/reference/cli.md new file mode 100644 index 0000000..806cffe --- /dev/null +++ b/repo_policy_sync/docs/reference/cli.md @@ -0,0 +1,92 @@ + + +# CLI reference + +```text +score-repo-policy-sync COMMAND [OPTIONS] +``` + +`COMMAND` is one of `plan`, `apply`, or `collect-samples`. Policy options may +also be set in the TOML configuration. Explicit +command-line values override values from the configuration. The organization +may therefore be supplied either with `--org` or in TOML. Report output paths +are CLI-only. + +## Commands + +| Command | Description | +| --- | --- | +| `plan` | Evaluate policies without changing repositories. Exit status `1` indicates drift. | +| `apply` | Apply policies and create or update policy-owned pull requests. | +| `collect-samples` | Collect policy-matching repository files into a local sample directory without changing repositories. Requires `--output DIRECTORY`. | + +## Typical + +| Option | Description | +| --- | --- | +| `--org NAME` | GitHub organization to scan. May be set in TOML. | +| `--policy NAME` | Select a local policy by directory name from the selected local policy directories. Repeat to select more than one. Defaults to all local policies. Bundled SCORE policies are included separately by default. | +| `--repo NAME` | Restrict the run to an exact repository name. Repeat to select more than one. | +| *(stdout)* | Always prints the terminal policy-evaluation table. | + +## Rare + +| Option | Description | +| --- | --- | +| `--config PATH` | TOML configuration file. Defaults to `score-repo-policy-sync.toml` in the current working directory when present. This and the report output path options 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. | +| `--exclude-bundled-policy NAME` | Exclude one bundled SCORE policy. Repeat to exclude more than one. All other bundled policies are included by default. | +| `--recreate` | On `apply`, rebuild one existing policy-owned pull request from its repository's current default branch. Requires exactly one `--repo` 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. | +| `--quiet`, `--no-quiet` | Suppress progress messages on standard error. The report remains on standard output. | + +## Debugging only + +| Option | Description | +| --- | --- | +| `--cache-dir PATH` | Directory for disposable checkouts. Defaults to the XDG cache directory. | +| `--sync-workers N` | Number of concurrent checkout refreshes. Defaults to the available CPU count, with a minimum of `1`. | +| `--policy-workers N` | Number of repositories evaluated or applied concurrently for each policy. Defaults to the available CPU count, with a minimum of `1`. Set to `1` to process policies serially. | +| `--output DIRECTORY` | On `collect-samples`, empty output directory for collected files and `inventory.json`. | + +Archived repositories are excluded from every run. Selecting an archived +repository with `--repo` fails validation instead of silently ignoring it. + +## Exit status + +| Status | Meaning | +| --- | --- | +| `0` | The plan found no required changes, or apply mode completed without errors. | +| `1` | Plan mode found one or more required changes. | +| `2` | Invalid input or an authentication, GitHub, Git, or policy execution error occurred. | + +## JSON report + +`--json-output PATH` writes one JSON document to `PATH`. The top-level +`schema_version` currently has value `2`. The `summary` object separately +reports repository synchronization, policy evaluation, pull-request activity +(including automatic closures), and elapsed duration; each `outcomes` element +includes the policy, repository, applicability result, status, planned or +applied changes, pull request URL, policy pull-request status, warnings, and +error. `--markdown-output PATH` +writes the compact Markdown matrix to `PATH`. JSON and Markdown output paths +can be supplied together so all reports are generated from one run. + +`collect-samples --output DIRECTORY` writes the matching repository files below +`DIRECTORY///before/` and an `inventory.json` describing +the collected cases. The directory must be empty or not yet exist. + +See the [configuration reference](configuration.md) for the TOML format. diff --git a/repo_policy_sync/docs/reference/configuration.md b/repo_policy_sync/docs/reference/configuration.md new file mode 100644 index 0000000..81bbe33 --- /dev/null +++ b/repo_policy_sync/docs/reference/configuration.md @@ -0,0 +1,66 @@ + + +# Configuration reference + +The optional `score-repo-policy-sync.toml` file in the current working +directory configures the policy command. Use `--config PATH` to select another +file. If `--config` is not supplied and the default file is absent, the command +continues with its defaults. `--config`, `--json-output`, and +`--markdown-output` are CLI-only. + +```toml +[score-repo-policy-sync] + +org = "eclipse-score" +policies = ["my-local-policy"] +repos = ["reference_integration"] + +# Relative paths are resolved relative to this TOML file. +policy_dirs = ["policies", "shared-policies"] + +# Bundled policies are enabled by default; list only intentional exclusions. +exclude_bundled_policies = [ + "score-devcontainer-dockerfile-migration", +] + +recreate = false +allow_dirty_pr = false +quiet = false +cache_dir = ".cache/repo-policy-sync" +sync_workers = 4 +policy_workers = 4 +``` + +The TOML keys map to the corresponding CLI options as follows: + +| TOML key | CLI option | +| --- | --- | +| `org` | `--org` | +| `policies` | repeated `--policy` | +| `repos` | repeated `--repo` | +| `policy_dirs` | repeated `--policy-dir` | +| `exclude_bundled_policies` | repeated `--exclude-bundled-policy` | +| `recreate` | `--recreate` / `--no-recreate` | +| `allow_dirty_pr` | `--allow-dirty-pr` / `--no-allow-dirty-pr` | +| `quiet` | `--quiet` / `--no-quiet` | +| `cache_dir` | `--cache-dir` | +| `sync_workers` | `--sync-workers` | +| `policy_workers` | `--policy-workers` | + +`policy_dirs` is optional. If it is omitted, `./policies` is used when that +directory exists. Setting it to `[]` disables local policy directories. + +`exclude_bundled_policies` accepts bundled policy directory names. When an option is present on the command line, its value replaces the +corresponding TOML value, including list-valued options. Unknown policy names +and unknown TOML fields are errors. diff --git a/repo_policy_sync/docs/reference/policy-format.md b/repo_policy_sync/docs/reference/policy-format.md new file mode 100644 index 0000000..825b294 --- /dev/null +++ b/repo_policy_sync/docs/reference/policy-format.md @@ -0,0 +1,350 @@ + + +# Policy format reference + +Each bundled policy has its own directory: + +```text +policies/ + example-policy/ + policy.yml + example-case/ + before/ + after/ +``` + +`policy.yml` is the policy definition. Each example case is an executable +before/after repository tree: the fixture test applies the real policy to +`before/`, compares the result with `after/`, and confirms that `after/` is +already compliant. Keep cases small and name them for the behavior they show. + +The policy directory is scanned recursively for `policy.yml` files. Every +policy must use the directory layout above so its directory name can provide +its ID. + +See the [bundled policy overview](../../policies/README.md) for the policies +shipped with this repository and their intended lifecycle. + +## Policy schema + +```yaml +title: "chore(docs): remove legacy score_docs_as_code configuration" +description: Replace legacy documentation configuration. + +when: + bazel: + direct_module_dependencies: [score_docs_as_code] + +ensure: + - type: ensure_line + path: .gitignore + line: _build + replace_line_globs: ["*_build*"] + rationale: Legacy generated-build entries are no longer used. + + - type: ensure_no_such_file + path: docs/ubproject.toml +``` + +`title` and a non-empty `ensure` list are required. `description` and `when` +are optional. Paths must be non-empty and +repository-relative; absolute paths and paths containing `..` are rejected. + +The policy directory name is the current, stable policy ID. It determines the +policy-owned branch name and pull-request marker. Renaming a policy is a +breaking change in the first version: update its callers and any existing +policy branch or pull request to the new ID before rollout. + +IDs are normalized to lowercase branch slugs by replacing non-alphanumeric +characters with hyphens. The loaded policy catalogue must not contain two +different IDs with the same normalized slug (for example, `foo_bar` and +`foo-bar`). + +## Follow-up commands + +`after_apply` runs a command after the policy has changed a repository. Each +command runs only when its `when_file_exists` path exists. Commands are lists, +not shell strings, and run from the repository root. Their conditional file is +included in the planned changes and commit, so generated files can be reviewed +in the policy pull request. + +```yaml +after_apply: + - command: [bazel, mod, deps] + when_file_exists: MODULE.bazel.lock + when_path_changed: MODULE.bazel + description: Regenerate MODULE.bazel.lock with `bazel mod deps`. +``` + +`when_path_changed` is optional. When present, the command is planned and run +only if the policy changed that repository-relative path. This avoids +regenerating derived files after unrelated policy changes. Forced follow-up +commands still run when their conditional file exists. + +## Conditions + +`when.file_exists` requires one repository-relative file to exist. + +`when.bazel.direct_module_dependencies` requires every listed module to be a +direct `bazel_dep(name = "…")` declaration in `MODULE.bazel`. The optional +`when.bazel.any_direct_module_dependencies` field requires at least one of its +listed modules to be direct, which is useful during module renames. + +The optional `when.bazel.any_direct_module_conditions` field accepts a +non-empty list of version comparisons such as +`score_platform < 0.7.0`. The list is combined with OR: one matching +condition is enough. Supported operators are `<`, `<=`, `==`, `!=`, `>=`, and +`>`, and versions must use `major.minor.patch` form. A dependency rename +configured with `replacement_name` is an implicit legacy trigger, so the old +module name does not need to be repeated in this condition list. + +```yaml +when: + bazel: + direct_module_dependencies: [score_platform, score_docs_as_code] + any_direct_module_dependencies: [score_process, score_process_description] + any_direct_module_conditions: + - score_platform < 0.7.0 + - score_docs_as_code < 8.0.0 + - score_process_description < 2.1.1 +``` + +`when.file_contains` requires a repository-relative UTF-8 text file to match a +Python regular expression. It accepts `path` and `pattern` fields. Multiple +conditions are combined with AND; `when.file_contains_any` accepts a non-empty +list of the same conditions and matches when at least one does. A path may also +contain `*`, `?`, or `[` glob syntax; for example, `**/BUILD` checks every +matching file. A repository that does not match is neither an error nor a +change. + +## Built-in operations + +The [built-in operations catalogue](../../operations/README.md) provides a +quick overview of every supported operation. This section remains the +authoritative reference for their schemas and detailed behavior. + +Every operation accepts an optional `rationale` string. When that operation +changes a repository, the generated pull request renders the rationale as a +nested bullet below the corresponding change. Keep it concise and explain why +the change is safe or necessary. + +### `ensure_line` + +```yaml +- type: ensure_line + path: .gitignore + line: _build + replace_lines: [/_build] + replace_line_globs: ["*_build*"] +``` + +Ensures one exact UTF-8 text line occurs exactly once. It removes exact +`replace_lines`, whole-line glob matches from `replace_line_globs`, and +duplicates of `line`. It inserts the desired line at the first removed or +existing match; without one, it appends the line. A missing file is created. +Globs use `*`, `?`, and `[...]` and match complete raw lines; comments and +whitespace have no special meaning. + +### `ensure_no_such_file` + +```yaml +- type: ensure_no_such_file + path: docs/obsolete.toml +``` + +Deletes a file when it exists. A missing file is compliant. The operation +refuses to delete a directory. + +### `replace_regex` + +```yaml +- type: replace_regex + path: MODULE.bazel + pattern: 'legacy = "([^"]+)"' + replacement: 'current = "\1"' +``` + +Applies Python `re.sub` semantics to the complete UTF-8 text file: every +non-overlapping match is replaced, with no implicit flags. Use inline regex +flags such as `(?s)` when needed. Capture groups in `replacement` use `\1` +syntax; invalid replacement backreferences are rejected while the policy is +loaded. A missing file, no match, or replacement that produces identical text +is compliant. Prefer narrow patterns and cover every intended layout with an +executable example. + +### `ensure_minimum_version` + +```yaml +- type: ensure_minimum_version + path: .bazelversion + minimum_version: 8.6.0 +``` + +Ensures an existing `major.minor.patch` version is at least the specified +numeric version. Lower versions are replaced with `minimum_version`; equal and +higher versions are unchanged. A missing file is compliant. A file containing +any other format is rejected so the policy cannot accidentally overwrite an +unknown version scheme. + +### `synchronize_workflow` + +```yaml +- type: synchronize_workflow + source: docs.yml + reusable_workflow: eclipse-score/cicd-workflows/.github/workflows/docs.yml + minimum_version: 0.0.3 + required_triggers: + - pull_request + - push + - merge_group + - release + - workflow_dispatch + workflow_run: + path: .github/workflows/docs-publish.yml + source: docs-publish.yml +``` + +Finds exactly one workflow below `.github/workflows` that calls the configured +reusable workflow. The existing workflow remains the structural source of +truth: its top-level `name`, local jobs, and job `with` values are preserved. +Missing trigger events are copied from the policy asset, and the reusable job +and permissions are synchronized. A lower reusable-workflow version is updated +to the policy asset's ref; a higher version or unknown immutable ref is +preserved. + +When `workflow_run` is configured, its workflow is synchronized in the same +operation. The `on.workflow_run.workflows` value is derived from the selected +build workflow's actual top-level `name`, so renaming or preserving a local +workflow name cannot silently break the workflow-run companion. + +The operation rejects zero or multiple matching build workflows and rejects +workflow assets that omit one of the declared `required_triggers`. + +### `synchronize_file` + +```yaml +- type: synchronize_file + path: .devcontainer/run-tool + source: run-tool + executable: true +``` + +Synchronizes a repository-relative UTF-8 file with a checked-in UTF-8 source +asset located relative to `policy.yml`. Missing or changed files are replaced; +when `executable: true` is set, the target is also made executable. This keeps +policy runs deterministic: update the checked-in source asset when intentionally +adopting a newer upstream version. + +For reusable GitHub workflow assets, `preserve_reusable_workflow_refs` can keep +an existing ref when it is at least the policy baseline or cannot be safely +ordered. A lower semantic version is replaced by the ref in the source asset; +branches and unknown immutable refs are preserved conservatively: + +```yaml +preserve_reusable_workflow_refs: + - workflow: eclipse-score/cicd-workflows/.github/workflows/docs.yml + minimum_version: 0.0.3 +``` + +Policies that set `preserve_workflow_content: true` merge the standard +workflow envelope into an existing workflow while retaining local jobs and +their `with` parameters. The source asset is authoritative for top-level +`permissions`: an explicit block is copied into the target, while an omitted +block removes any existing top-level permissions. Jobs needing different +permissions must declare them at job level. + +### `synchronize_devcontainer_version` + +```yaml +- type: synchronize_devcontainer_version + dockerfile: .devcontainer/Dockerfile + module_file: MODULE.bazel + image: ghcr.io/eclipse-score/devcontainer + module_name: score_devcontainer +``` + +Synchronizes one Docker `FROM image:vX.Y.Z` instruction with one direct +`bazel_dep(name = "module_name", version = "X.Y.Z")` declaration. It retains +the higher numeric version and rewrites only the lower version text. Missing, +duplicate, or unsupported declarations raise an error rather than modifying an +ambiguous repository. + +### `ensure_bazel_dependency` + +```yaml +- type: ensure_bazel_dependency + dockerfile: .devcontainer/Dockerfile + module_file: MODULE.bazel + image: ghcr.io/eclipse-score/devcontainer + module_name: score_devcontainer +``` + +Ensures that `MODULE.bazel` contains one valid direct dependency for the module +named by `module_name`. The dependency version is read from the single +`FROM image:vX.Y.Z` instruction in `dockerfile`. A missing dependency is added; +an existing valid dependency is left for a separate synchronization policy. +Duplicate, malformed, or unsupported declarations raise an error. + +### `synchronize_bazel_dependencies` + +```yaml +- type: synchronize_bazel_dependencies + module_file: MODULE.bazel + dependencies: + - name: score_platform + version: 0.7.0 + optional: true + - name: score_process + replacement_name: score_process_description + version: 2.1.0 + optional: true + - name: score_baselibs + version: 0.2.11 + override: bf0020fefef402642dcb0092832e03ba4267d739 + remote: https://github.com/eclipse-score/baselibs.git + build_file_names: [BUILD, BUILD.bazel] +``` + +Synchronizes the listed direct `bazel_dep` declarations to at least their +target versions. A dependency with `replacement_name` is renamed and moved to +the target version. Set `optional: true` for dependencies that are only used +by some repositories; absent optional dependencies are skipped. Every matching +BUILD file is scanned recursively and legacy references to the old module name +are renamed as well. Missing required, duplicate, or malformed configured +dependencies raise an error. `override` together with `remote` adds or updates +a matching `git_override(module_name = "...", commit = "...", remote = "...")` +declaration. Overrides for absent optional dependencies are skipped. + +### `migrate_devcontainer_json` + +```yaml +- type: migrate_devcontainer_json + sources: [.devcontainer.json, .devcontainer/devcontainer.json] + destination: .devcontainer/devcontainer.json + dockerfile: .devcontainer/Dockerfile + image: ghcr.io/eclipse-score/devcontainer + dockerfile_comment: "# Use Dockerfile to get dependabot version bumps after new image is released" + copyright_header_source: copyright-header + copyright_header_organization: eclipse-score +``` + +Moves an image-based devcontainer configuration containing exactly one +`image: image:vX.Y.Z` entry to `destination`, replaces the image entry with a +`build` entry pointing to the Dockerfile, and removes the source file. Existing +Dockerfiles or destination files with different contents and unsupported image +tags are rejected rather than overwritten. When `copyright_header_organization` +is set, the configured header from `copyright_header_source` is added only when +the policy is applied to `copyright_header_organization`. The Dockerfile +comment is configured with `dockerfile_comment` so the operation does not +assume a particular organization or tooling convention. diff --git a/repo_policy_sync/docs/tutorials/first-policy.md b/repo_policy_sync/docs/tutorials/first-policy.md new file mode 100644 index 0000000..6cfdfca --- /dev/null +++ b/repo_policy_sync/docs/tutorials/first-policy.md @@ -0,0 +1,73 @@ + + +# Tutorial: create your first policy + +This tutorial creates, tests, and plans a small policy that ensures generated +build output is ignored. It assumes you have completed the installation steps +in the [project README](../../README.md). + +Bundled policies are small, versioned specifications with executable examples. +Create a directory under `repo_policy_sync/policies/` and give it a stable, +lowercase directory name: + +```text +policies/ + ensure-build-directory-ignored/ + policy.yml + missing-ignore-rule/ + before/ + after/ +``` + +Start with a minimal definition: + +```yaml +title: "chore: ignore generated build directory" +description: Keep generated build output out of version control. + +ensure: + - type: ensure_line + path: .gitignore + line: _build + rationale: Build output is generated locally. +``` + +The policy directory name (`ensure-build-directory-ignored`) is its permanent +ID and determines the policy-owned branch and pull-request marker. Keep it +stable after rollout; renaming a policy is a first-version breaking change. + +Add the smallest representative input tree under `before/`, then the exact +expected tree under `after/`. The fixture test applies every bundled policy, +compares its output with `after/`, and confirms a second application makes no +changes: + +```bash +uv run pytest -q repo_policy_sync/tests/test_policy_fixtures.py +``` + +Run the complete policy-sync suite before rolling out a policy: + +```bash +uv run pytest -q repo_policy_sync/tests +``` + +Use plan mode against one known repository before enabling apply mode for an +organization: + +```bash +uv run score-repo-policy-sync plan \ + --org eclipse-score \ + --repo example-repository \ + --policy ensure-build-directory-ignored +``` diff --git a/repo_policy_sync/eclipse-score.toml b/repo_policy_sync/eclipse-score.toml new file mode 100644 index 0000000..56f97cb --- /dev/null +++ b/repo_policy_sync/eclipse-score.toml @@ -0,0 +1,2 @@ +[score-repo-policy-sync] +org = "eclipse-score" diff --git a/repo_policy_sync/engine.py b/repo_policy_sync/engine.py new file mode 100644 index 0000000..cb7f487 --- /dev/null +++ b/repo_policy_sync/engine.py @@ -0,0 +1,320 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Candidate selection and local policy evaluation.""" + +from __future__ import annotations + +import os +import re +import shlex +import subprocess +import tempfile +from pathlib import Path + +from .bazel import ( + matches_bazel_dependency_condition, + parse_bazel_version, + starlark_call_ranges, +) +from .errors import CommandError, RepoPolicySyncError, redact_sensitive_text +from .models import Change, Evaluation, Policy, SynchronizeBazelDependencies +from .operations import apply as apply_operation +from .operations import describe_changes +from .operations._validation import validate_repository_path + +_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") +_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*[\"']([^\"']+)[\"']") +_REDUCED_ENVIRONMENT_KEYS = { + "CI", + "LANG", + "PATH", + "SHELL", + "TERM", + "TMPDIR", + "TMP", + "TEMP", + "USER", + "LOGNAME", +} + + +def evaluate_policy( + root: Path, policy: Policy, *, organization: str | None = None +) -> Evaluation: + """Evaluate a policy against a checked-out repository without changing it.""" + + if not _matches_conditions(root, policy): + return Evaluation(applies=False, changes=()) + changes: list[Change] = [] + for operation in policy.ensure: + changes.extend(describe_changes(root, operation, organization=organization)) + if changes: + changes.extend( + Change(command.when_file_exists, command.description) + for command in policy.after_apply + if _should_run_after_apply( + root, command, {change.path for change in changes} + ) + ) + return Evaluation(applies=True, changes=tuple(changes)) + + +def matches_policy_conditions(root: Path, policy: Policy) -> bool: + """Return whether a checked-out repository matches a policy's ``when``.""" + + return _matches_conditions(root, policy) + + +def policy_sample_paths(root: Path, policy: Policy) -> tuple[Path, ...]: + """Return repository files relevant for a policy sample collection.""" + + conditions = [] + if policy.file_contains_condition is not None: + conditions.append(policy.file_contains_condition) + if policy.file_contains_any_condition is not None: + conditions.extend(policy.file_contains_any_condition.conditions) + if conditions: + paths = { + path + for condition in conditions + for path in _condition_paths(root, condition.path) + if re.search(condition.pattern, path.read_text(encoding="utf-8")) + } + return tuple(sorted(paths)) + + workflows = root / ".github/workflows" + validate_repository_path(root, workflows) + if not workflows.is_dir(): + return () + return tuple( + sorted( + path + for path in workflows.rglob("*") + if path.is_file() and path.suffix in {".yml", ".yaml"} + ) + ) + + +def apply_policy( + root: Path, + policy: Policy, + *, + force_after_apply: bool = False, + organization: str | None = None, +) -> Evaluation: + """Apply a matching policy and return the changes that were made.""" + + evaluation = evaluate_policy(root, policy, organization=organization) + if not evaluation.applies: + return evaluation + for operation in policy.ensure: + apply_operation(root, operation, organization=organization) + if evaluation.changes or force_after_apply: + changed_paths = {change.path for change in evaluation.changes} + for command in policy.after_apply: + if _should_run_after_apply( + root, command, changed_paths, force=force_after_apply + ): + _run_after_apply_command(root, command.command) + if not force_after_apply: + return evaluation + changes = list(evaluation.changes) + existing_paths = {change.path for change in changes} + changes.extend( + Change(command.when_file_exists, command.description) + for command in policy.after_apply + if _should_run_after_apply(root, command, set(), force=True) + and command.when_file_exists not in existing_paths + ) + return Evaluation(evaluation.applies, tuple(changes)) + + +def _run_after_apply_command(root: Path, command: tuple[str, ...]) -> None: + # Policy commands execute repository-controlled code. Keep only the basic + # process environment and replace user configuration with temporary paths so + # credentials and host-specific settings are not inherited accidentally. + environment = { + key: value + for key, value in os.environ.items() + if key in _REDUCED_ENVIRONMENT_KEYS or key.startswith("LC_") + } + try: + with tempfile.TemporaryDirectory( + prefix="repo-policy-sync-after-apply-" + ) as home: + environment.update( + { + "HOME": home, + "XDG_CONFIG_HOME": str(Path(home) / ".config"), + "GH_CONFIG_DIR": str(Path(home) / ".gh"), + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TERMINAL_PROMPT": "0", + } + ) + subprocess.run( + command, + cwd=root, + check=True, + capture_output=True, + text=True, + env=environment, + ) + except FileNotFoundError as exc: + raise CommandError(f"required command is unavailable: {command[0]}") from exc + except subprocess.CalledProcessError as exc: + detail = ( + (exc.stderr or "").strip() or (exc.stdout or "").strip() or "command failed" + ) + raise CommandError( + f"{redact_sensitive_text(shlex.join(command))}: " + f"{redact_sensitive_text(detail)} (exit status {exc.returncode})" + ) from exc + + +def _should_run_after_apply( + root: Path, command, changed_paths: set[Path], *, force: bool = False +) -> bool: + path = root / command.when_file_exists + validate_repository_path(root, path) + return path.is_file() and ( + force + or command.when_path_changed is None + or command.when_path_changed in changed_paths + ) + + +def _matches_conditions(root: Path, policy: Policy) -> bool: + return ( + _matches_bazel_condition(root, policy) + and _matches_file_exists_condition(root, policy) + and _matches_file_contains_condition(root, policy) + and _matches_file_contains_any_condition(root, policy) + ) + + +def _matches_bazel_condition(root: Path, policy: Policy) -> bool: + condition = policy.bazel_condition + if condition is None: + return True + module_file = root / "MODULE.bazel" + validate_repository_path(root, module_file) + if not module_file.is_file(): + return False + text = module_file.read_text(encoding="utf-8") + dependencies: dict[str, tuple[int, int, int] | None] = {} + for start, end in starlark_call_ranges(text, "bazel_dep"): + body = text[start:end] + name_match = _NAME_ARGUMENT.search(body) + if name_match is None: + continue + version_match = _VERSION_ARGUMENT.search(body) + dependencies[name_match.group(1)] = ( + parse_bazel_version(version_match.group(1)) if version_match else None + ) + condition_names = { + dependency_condition.module_name + for dependency_condition in condition.any_direct_module_conditions + } + invalid_versions = sorted( + name + for name in condition_names + if name in dependencies and dependencies[name] is None + ) + if invalid_versions: + # A configured version condition cannot be evaluated meaningfully for a + # missing or non-numeric version; fail loudly instead of silently + # treating a malformed dependency as a non-match. + names = ", ".join(repr(name) for name in invalid_versions) + raise RepoPolicySyncError( + f"MODULE.bazel configured bazel_dep versions must be numeric major.minor.patch: {names}" + ) + # A policy can require a complete set and also accept one of several names. + dependency_names = set(dependencies) + if not set(condition.direct_module_dependencies).issubset(dependency_names): + return False + if ( + condition.any_direct_module_dependencies + and not set(condition.any_direct_module_dependencies) & dependency_names + ): + return False + if not condition.any_direct_module_conditions: + return True + + # A replacement configured by the synchronization operation is implicitly a + # legacy trigger. This keeps the policy YAML from repeating that fact. + legacy_names = { + dependency.module_name + for operation in policy.ensure + if isinstance(operation, SynchronizeBazelDependencies) + for dependency in operation.dependencies + if dependency.replacement_name is not None + } + if legacy_names & dependency_names: + return True + return any( + (version := dependencies.get(dependency_condition.module_name)) is not None + and matches_bazel_dependency_condition(version, dependency_condition) + for dependency_condition in condition.any_direct_module_conditions + ) + + +def _matches_file_exists_condition(root: Path, policy: Policy) -> bool: + condition = policy.file_exists_condition + if condition is None: + return True + path = root / condition.path + validate_repository_path(root, path) + return path.is_file() + + +def _matches_file_contains_condition(root: Path, policy: Policy) -> bool: + condition = policy.file_contains_condition + if condition is None: + return True + return any( + re.search(condition.pattern, path.read_text(encoding="utf-8")) is not None + for path in _condition_paths(root, condition.path) + ) + + +def _matches_file_contains_any_condition(root: Path, policy: Policy) -> bool: + condition = policy.file_contains_any_condition + if condition is None: + return True + return any( + re.search(item.pattern, path.read_text(encoding="utf-8")) is not None + for item in condition.conditions + for path in _condition_paths(root, item.path) + ) + + +def _condition_paths(root: Path, path: Path) -> tuple[Path, ...]: + """Return matching repository files for a literal path or a relative glob.""" + + # Literal paths are common, so avoid glob expansion and keep their behavior simple. + if not any(character in str(path) for character in "*?["): + candidate = root / path + validate_repository_path(root, candidate) + return (candidate,) if candidate.is_file() else () + # Glob conditions are used for files such as BUILD files at any directory depth. + candidates: list[Path] = [] + for candidate in sorted(root.glob(str(path))): + relative = candidate.relative_to(root) + # Git metadata is not part of the repository content being evaluated. + if ".git" in relative.parts: + continue + validate_repository_path(root, candidate) + if candidate.is_file(): + candidates.append(candidate) + return tuple(candidates) diff --git a/repo_policy_sync/errors.py b/repo_policy_sync/errors.py new file mode 100644 index 0000000..0ed4b54 --- /dev/null +++ b/repo_policy_sync/errors.py @@ -0,0 +1,64 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Errors raised for invalid policy and runtime input.""" + +from __future__ import annotations + +import os +import re + + +_GITHUB_TOKEN = re.compile( + r"(? str: + """Remove common credential forms before text reaches a user or PR.""" + + for variable in ("GH_TOKEN", "GITHUB_TOKEN"): + secret = os.environ.get(variable) + if secret and len(secret) >= 8: + value = value.replace(secret, "[REDACTED]") + value = _GITHUB_TOKEN.sub("[REDACTED]", value) + value = _BEARER_CREDENTIAL.sub(r"\1[REDACTED]", value) + value = _NAMED_CREDENTIAL.sub(r"\1[REDACTED]", value) + value = _OPTION_CREDENTIAL.sub(r"\1[REDACTED]", value) + return _URL_CREDENTIAL.sub(r"\1[REDACTED]@", value) + + +class RepoPolicySyncError(RuntimeError): + """Base error presented to Repository Policy Sync users without a traceback.""" + + def __init__(self, message: object) -> None: + super().__init__(redact_sensitive_text(str(message))) + + +class PolicyError(RepoPolicySyncError): + """A policy file does not conform to the supported schema.""" + + +class CommandError(RepoPolicySyncError): + """An external gh or Git command failed.""" diff --git a/repo_policy_sync/github.py b/repo_policy_sync/github.py new file mode 100644 index 0000000..3717d11 --- /dev/null +++ b/repo_policy_sync/github.py @@ -0,0 +1,891 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Small subprocess boundary for pre-authenticated gh and Git commands.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass +from importlib.resources import files +from pathlib import Path +from urllib.parse import urlparse + +from .errors import CommandError, redact_sensitive_text +from .models import Change, Policy, Repository, policy_branch_slug + +TOOL_SLUG = "repo-policy-sync" +AUTOMATION_LABELS = ("automation", TOOL_SLUG) +AUTOMATION_LABEL_COLOR = "EDEDED" +_PRE_COMMIT_ENVIRONMENT_KEYS = { + "CI", + "LANG", + "PATH", + "SHELL", + "TERM", + "TMPDIR", + "TMP", + "TEMP", + "USER", + "LOGNAME", +} + + +@dataclass(frozen=True) +class PullRequest: + number: int + url: str + expected_head_oid: str | None = None + warnings: tuple[str, ...] = () + branch: str = "" + merged_at: str | None = None + body: str | None = None + mergeable: str | None = None + + +@dataclass(frozen=True) +class PolicyPullRequestStatus: + """The relevant current and historical PRs for one repository policy.""" + + open: PullRequest | None = None + merged: PullRequest | None = None + + +@dataclass(frozen=True) +class CommitResult: + """The published commit and any pre-commit failure allowed by the caller.""" + + head_oid: str + pre_commit_failure: str | None = None + + +class GitHubCli: + """Run the minimal gh/Git command set required by this tool.""" + + def ensure_authenticated(self) -> None: + self._run(["gh", "auth", "status"]) + + def list_repositories(self, *, org: str) -> tuple[Repository, ...]: + """List every repository in an organization with its default branch.""" + + output = self._run( + ["gh", "api", "--paginate", "--slurp", f"/orgs/{org}/repos?per_page=100"] + ) + try: + pages = json.loads(output) + except json.JSONDecodeError as exc: + raise CommandError( + f"gh returned invalid repository JSON for {org}" + ) from exc + if not isinstance(pages, list): + raise CommandError(f"gh returned invalid repository JSON for {org}") + repositories: list[Repository] = [] + for page in pages: + if not isinstance(page, list): + raise CommandError(f"gh returned invalid repository JSON for {org}") + for raw in page: + if not isinstance(raw, dict): + raise CommandError(f"gh returned invalid repository JSON for {org}") + name = raw.get("name") + default_branch = raw.get("default_branch") + archived = raw.get("archived", False) + if not isinstance(name, str) or not name: + raise CommandError( + f"gh returned a repository without a valid name for {org}" + ) + if default_branch is not None and not isinstance(default_branch, str): + raise CommandError( + f"gh returned an invalid default branch for {org}/{name}" + ) + if not isinstance(archived, bool): + raise CommandError( + f"gh returned an invalid archived state for {org}/{name}" + ) + repositories.append(Repository(name, default_branch, archived)) + return tuple(repositories) + + def sync_default_branch( + self, *, repository: str, branch: str, destination: Path + ) -> None: + """Clone once, then refresh a disposable cached checkout on later runs.""" + + if (destination / ".git").is_dir(): + self._verify_cached_remote(repository=repository, checkout=destination) + self._run( + [ + "git", + "-C", + str(destination), + "fetch", + "--depth", + "1", + "origin", + branch, + ] + ) + self._run( + [ + "git", + "-C", + str(destination), + "checkout", + "--detach", + "--force", + "FETCH_HEAD", + ] + ) + self._run(["git", "-C", str(destination), "clean", "-fdx"]) + self._run( + [ + "git", + "-C", + str(destination), + "update-ref", + f"refs/{TOOL_SLUG}/default", + "HEAD", + ] + ) + return + if destination.exists(): + raise CommandError( + f"checkout cache path exists but is not a Git repository: {destination}" + ) + destination.parent.mkdir(parents=True, exist_ok=True) + self._run( + [ + "gh", + "repo", + "clone", + repository, + str(destination), + "--", + "--depth", + "1", + "--branch", + branch, + ] + ) + self._run( + [ + "git", + "-C", + str(destination), + "update-ref", + f"refs/{TOOL_SLUG}/default", + "HEAD", + ] + ) + + def _verify_cached_remote(self, *, repository: str, checkout: Path) -> None: + expected_url = self._run( + ["gh", "repo", "view", repository, "--json", "url", "--jq", ".url"] + ) + actual_url = self._run( + ["git", "-C", str(checkout), "remote", "get-url", "origin"] + ) + expected = _remote_identity(expected_url) + actual = _remote_identity(actual_url) + if expected is None or actual is None or expected != actual: + raise CommandError( + f"checkout cache remote does not match requested repository {repository}" + ) + + def restore_synced_default_branch(self, *, checkout: Path) -> None: + """Discard a preceding policy's local changes without fetching again.""" + + self._run( + [ + "git", + "-C", + str(checkout), + "checkout", + "--detach", + "--force", + f"refs/{TOOL_SLUG}/default", + ] + ) + self._run(["git", "-C", str(checkout), "clean", "-fdx"]) + + def find_open_pull_request( + self, + *, + repository: str, + branches: tuple[str, ...], + policy_id: str, + ) -> PullRequest | None: + """Find one PR owned by the policy.""" + + pull_requests = self._find_policy_pull_requests( + repository=repository, + branches=branches, + policy_id=policy_id, + state="open", + ) + if len(pull_requests) > 1: + urls = ", ".join(pull_request.url for pull_request in pull_requests) + raise CommandError( + f"multiple open pull requests match policy {policy_id} in {repository}: {urls}" + ) + return pull_requests[0] if pull_requests else None + + def find_policy_pull_request_status( + self, + *, + repository: str, + branches: tuple[str, ...], + policy_id: str, + ) -> PolicyPullRequestStatus: + """Find the open PR and latest merged PR owned by a repository policy.""" + + open_pull_requests = self._find_policy_pull_requests( + repository=repository, + branches=branches, + policy_id=policy_id, + state="open", + ) + if len(open_pull_requests) > 1: + urls = ", ".join(pull_request.url for pull_request in open_pull_requests) + raise CommandError( + f"multiple open pull requests match policy {policy_id} in {repository}: {urls}" + ) + merged_pull_requests = self._find_policy_pull_requests( + repository=repository, + branches=branches, + policy_id=policy_id, + state="merged", + ) + latest_merged = max( + merged_pull_requests, + key=lambda pull_request: ( + pull_request.merged_at or "", + pull_request.number, + ), + default=None, + ) + return PolicyPullRequestStatus( + open=open_pull_requests[0] if open_pull_requests else None, + merged=latest_merged, + ) + + def _find_policy_pull_requests( + self, + *, + repository: str, + branches: tuple[str, ...], + policy_id: str, + state: str, + ) -> tuple[PullRequest, ...]: + """Find policy-owned PRs in one GitHub state across its branch.""" + + owned: list[PullRequest] = [] + accepted_marker = _policy_marker(policy_id) + fields = ( + "number,url,body,mergedAt" + if state == "merged" + else "number,url,body,mergeable" + ) + for branch in branches: + output = self._run( + [ + "gh", + "pr", + "list", + "--repo", + repository, + "--head", + branch, + "--state", + state, + "--json", + fields, + ] + ) + try: + pull_requests = json.loads(output) + except json.JSONDecodeError as exc: + raise CommandError( + f"gh returned invalid pull-request JSON for {repository}" + ) from exc + if not isinstance(pull_requests, list): + raise CommandError( + f"gh returned invalid pull-request JSON for {repository}" + ) + for pull_request in pull_requests: + if not isinstance(pull_request, dict): + raise CommandError( + f"gh returned invalid pull-request JSON for {repository}" + ) + raw_body = pull_request.get("body", "") + body = "" if raw_body is None else raw_body + if not isinstance(body, str): + raise CommandError( + f"gh returned invalid pull-request JSON for {repository}" + ) + if accepted_marker not in body: + if state == "merged": + # Merged history may contain an unrelated PR from a + # previous branch user; only an open PR can block reuse. + continue + raise CommandError( + f"refusing to reuse {repository} branch {branch}: its {state} pull request " + f"is not owned by policy {policy_id}" + ) + number = pull_request.get("number") + url = pull_request.get("url") + if not isinstance(number, int) or not isinstance(url, str): + raise CommandError( + f"gh returned invalid pull-request JSON for {repository}" + ) + merged_at = pull_request.get("mergedAt") + if merged_at is not None and not isinstance(merged_at, str): + raise CommandError( + f"gh returned invalid pull-request JSON for {repository}" + ) + mergeable = pull_request.get("mergeable") + if mergeable is not None and not isinstance(mergeable, str): + raise CommandError( + f"gh returned invalid pull-request JSON for {repository}" + ) + owned.append( + PullRequest( + number=number, + url=url, + expected_head_oid=_policy_head_marker_from_body(body), + branch=branch, + merged_at=merged_at, + body=body, + mergeable=mergeable, + ) + ) + return tuple(owned) + + def switch_to_policy_branch( + self, *, checkout: Path, branch: str, exists_remotely: bool + ) -> None: + if exists_remotely: + self._run(["git", "-C", str(checkout), "fetch", "origin", branch]) + self._run( + ["git", "-C", str(checkout), "switch", "-C", branch, "FETCH_HEAD"] + ) + else: + # A cached checkout can retain a local branch from a failed run. + # It is disposable, so recreate that branch from the freshly synced + # default branch instead of failing because the name already exists. + self._run(["git", "-C", str(checkout), "switch", "-C", branch]) + + def recreate_policy_branch(self, *, checkout: Path, branch: str) -> None: + """Start a policy branch again from the already-synced default branch.""" + + self._run(["git", "-C", str(checkout), "switch", "-C", branch]) + + def verify_policy_branch_head( + self, *, checkout: Path, branch: str, expected_head_oid: str + ) -> None: + """Refuse to alter a policy branch whose head changed outside this tool.""" + + output = self._run( + ["git", "-C", str(checkout), "ls-remote", "origin", f"refs/heads/{branch}"] + ) + actual_head_oid = output.split(maxsplit=1)[0] if output.strip() else "" + if actual_head_oid != expected_head_oid: + raise CommandError( + f"refusing to modify policy branch {branch}: expected {expected_head_oid}, " + f"found {actual_head_oid or 'no remote branch'}" + ) + + def commit_and_push( + self, + *, + checkout: Path, + branch: str, + policy: Policy, + changes: tuple[Change, ...], + allow_dirty_pr: bool = False, + ) -> CommitResult: + paths = tuple(dict.fromkeys(str(change.path) for change in changes)) + self._run(["git", "-C", str(checkout), "add", "-A", "--", *paths]) + pre_commit_ran, pre_commit_failure = self._run_pre_commit( + checkout=checkout, paths=paths, allow_dirty_pr=allow_dirty_pr + ) + if pre_commit_ran: + self._run(["git", "-C", str(checkout), "add", "-A", "--", *paths]) + self._run(["git", "-C", str(checkout), "commit", "-m", policy.title]) + self._run( + ["git", "-C", str(checkout), "push", "--set-upstream", "origin", branch] + ) + return CommitResult( + head_oid=self._run( + ["git", "-C", str(checkout), "rev-parse", "HEAD"] + ).strip(), + pre_commit_failure=pre_commit_failure, + ) + + def run_pre_commit( + self, *, checkout: Path, paths: tuple[str, ...] | None = None + ) -> bool: + """Run every configured pre-commit hook before publishing policy changes. + + A non-zero result is allowed one retry because formatter hooks commonly + fix files and use their first run to report that they changed them. + The caller stages those fixes after this method returns. + """ + + if not (checkout / ".pre-commit-config.yaml").is_file(): + return False + if paths is not None and not paths: + return False + environment = { + key: value + for key, value in os.environ.items() + if key in _PRE_COMMIT_ENVIRONMENT_KEYS or key.startswith("LC_") + } + with tempfile.TemporaryDirectory(prefix=f"{TOOL_SLUG}-pre-commit-") as home: + environment.update( + { + "HOME": home, + "XDG_CONFIG_HOME": str(Path(home) / ".config"), + "GH_CONFIG_DIR": str(Path(home) / ".gh"), + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TERMINAL_PROMPT": "0", + } + ) + command = ["pre-commit", "run", "--all-files"] + if paths is not None: + command = ["pre-commit", "run", "--files", *paths] + self._run(command, cwd=checkout, env=environment) + return True + + def _run_pre_commit( + self, *, checkout: Path, paths: tuple[str, ...], allow_dirty_pr: bool + ) -> tuple[bool, str | None]: + """Run pre-commit twice when needed so formatter fixes can be published cleanly.""" + + existing_paths = tuple(path for path in paths if (checkout / path).exists()) + if not existing_paths: + return False, None + try: + ran = self.run_pre_commit(checkout=checkout, paths=existing_paths) + except CommandError: + self._run(["git", "-C", str(checkout), "add", "-A", "--", *paths]) + try: + ran = self.run_pre_commit(checkout=checkout, paths=existing_paths) + except CommandError as exc: + if not allow_dirty_pr: + raise + return True, str(exc) + # The first attempt ran, so its formatting changes must be staged + # even if the configuration disappears before the retry. + return True, None + return ran, None + + def has_changes(self, *, checkout: Path, changes: tuple[Change, ...]) -> bool: + paths = tuple(dict.fromkeys(str(change.path) for change in changes)) + if not paths: + return False + return bool( + self._run( + [ + "git", + "-C", + str(checkout), + "status", + "--short", + "--untracked-files=all", + "--", + *paths, + ] + ).strip() + ) + + def commit_and_force_push( + self, + *, + checkout: Path, + branch: str, + expected_head_oid: str, + policy: Policy, + changes: tuple[Change, ...], + allow_dirty_pr: bool = False, + ) -> CommitResult: + paths = tuple(dict.fromkeys(str(change.path) for change in changes)) + self._run(["git", "-C", str(checkout), "add", "-A", "--", *paths]) + pre_commit_ran, pre_commit_failure = self._run_pre_commit( + checkout=checkout, paths=paths, allow_dirty_pr=allow_dirty_pr + ) + if pre_commit_ran: + self._run(["git", "-C", str(checkout), "add", "-A", "--", *paths]) + self._run(["git", "-C", str(checkout), "commit", "-m", policy.title]) + self._run( + [ + "git", + "-C", + str(checkout), + "push", + f"--force-with-lease=refs/heads/{branch}:{expected_head_oid}", + "--set-upstream", + "origin", + branch, + ] + ) + return CommitResult( + head_oid=self._run( + ["git", "-C", str(checkout), "rev-parse", "HEAD"] + ).strip(), + pre_commit_failure=pre_commit_failure, + ) + + def create_pull_request( + self, + *, + repository: str, + base: str, + branch: str, + policy: Policy, + changes: tuple[Change, ...], + head_oid: str, + draft: bool = False, + ) -> PullRequest: + self._ensure_automation_labels(repository=repository) + create_command = [ + "gh", + "pr", + "create", + "--repo", + repository, + "--base", + base, + "--head", + branch, + "--title", + policy.title, + "--body", + _pull_request_body(policy, changes, head_oid=head_oid), + ] + if draft: + create_command.insert(3, "--draft") + output = self._run(create_command).strip() + if not output: + raise CommandError(f"gh did not return a pull-request URL for {repository}") + warnings: list[str] = [] + for label in AUTOMATION_LABELS: + try: + self._run(["gh", "pr", "edit", output, "--add-label", label]) + except CommandError as exc: + warnings.append(f"label {label!r} was not applied: {exc}") + return PullRequest(number=0, url=output, warnings=tuple(warnings)) + + def _ensure_automation_labels(self, *, repository: str) -> None: + """Create the labels applied to generated pull requests when they are absent.""" + + output = self._run( + [ + "gh", + "api", + "--paginate", + "--slurp", + f"/repos/{repository}/labels?per_page=100", + ] + ) + try: + pages = json.loads(output) + except json.JSONDecodeError as exc: + raise CommandError( + f"gh returned invalid label JSON for {repository}" + ) from exc + if not isinstance(pages, list): + raise CommandError(f"gh returned invalid label JSON for {repository}") + + existing_labels: set[str] = set() + for page in pages: + if not isinstance(page, list): + raise CommandError(f"gh returned invalid label JSON for {repository}") + for label in page: + if not isinstance(label, dict): + raise CommandError( + f"gh returned invalid label JSON for {repository}" + ) + name = label.get("name") + if not isinstance(name, str) or not name: + raise CommandError( + f"gh returned invalid label JSON for {repository}" + ) + existing_labels.add(name) + + for label in AUTOMATION_LABELS: + if label not in existing_labels: + self._run( + [ + "gh", + "api", + "--method", + "POST", + f"/repos/{repository}/labels", + "-f", + f"name={label}", + "-f", + f"color={AUTOMATION_LABEL_COLOR}", + ] + ) + + def update_pull_request( + self, + *, + repository: str, + pull_request: PullRequest, + policy: Policy, + changes: tuple[Change, ...], + head_oid: str, + failure: str | None = None, + ) -> None: + """Keep an existing policy-owned pull request's explanation current.""" + + self._run( + [ + "gh", + "pr", + "edit", + pull_request.url, + "--repo", + repository, + "--title", + policy.title, + "--body", + _pull_request_body(policy, changes, head_oid=head_oid, failure=failure), + ] + ) + + def close_pull_request(self, *, repository: str, pull_request: PullRequest) -> None: + """Close a policy-owned pull request after ownership is verified.""" + + # A closed generated PR no longer needs its policy branch. Removing it + # prevents stale branch contents from being mistaken for current work. + self._run( + [ + "gh", + "pr", + "close", + pull_request.url, + "--repo", + repository, + "--delete-branch", + ] + ) + + def mark_pull_request_draft( + self, *, repository: str, pull_request: PullRequest + ) -> None: + """Keep a pull request in draft state until its dirty changes are fixed.""" + + self._run( + ["gh", "pr", "ready", pull_request.url, "--repo", repository, "--undo"] + ) + + def comment_on_pull_request( + self, *, repository: str, pull_request: PullRequest, failure: str + ) -> None: + """Explain why a dirty draft pull request was created.""" + + self._run( + [ + "gh", + "pr", + "comment", + pull_request.url, + "--repo", + repository, + "--body", + _pre_commit_failure_comment(failure), + ] + ) + + @staticmethod + def _run( + command: list[str], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, + ) -> str: + try: + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + cwd=cwd, + env=env, + ) + except FileNotFoundError as exc: + raise CommandError( + f"required command is unavailable: {command[0]}" + ) from exc + except subprocess.CalledProcessError as exc: + detail = exc.stderr.strip() or exc.stdout.strip() or "command failed" + raise CommandError(f"{' '.join(command[:3])}: {detail}") from exc + return result.stdout + + +def policy_branch(policy_id: str) -> str: + """Map a stable policy identifier to a safe, deterministic branch name.""" + + slug = policy_branch_slug(policy_id) + if not slug: + raise ValueError(f"policy ID cannot produce a branch name: {policy_id!r}") + return f"{TOOL_SLUG}/{slug}" + + +def _remote_identity(value: str) -> tuple[str, str] | None: + """Normalize HTTPS, SSH, and scp-like Git remotes for safe comparison.""" + + value = value.strip() + if not value: + return None + if "://" in value: + parsed = urlparse(value) + host = parsed.hostname + path = parsed.path + else: + match = re.match(r"^(?:[^@]+@)?([^:]+):(.+)$", value) + if match is None: + return None + host, path = match.groups() + if not host or not path: + return None + normalized_path = path.strip("/") + if normalized_path.endswith(".git"): + normalized_path = normalized_path[:-4] + if not normalized_path: + return None + return host.lower(), normalized_path.lower() + + +def policy_branches(policy: Policy) -> tuple[str, ...]: + """Return the deterministic branch that owns this policy's PR.""" + + return (policy_branch(policy.id),) + + +def _policy_marker(policy_id: str) -> str: + return f"" + + +def _policy_head_marker(head_oid: str) -> str: + return f"" + + +def _policy_head_marker_from_body(body: str) -> str | None: + match = re.search(rf"", body) + return match.group(1) if match else None + + +def _pull_request_body( + policy: Policy, + changes: tuple[Change, ...], + *, + head_oid: str, + failure: str | None = None, +) -> str: + """Build the concise, policy-centred pull-request template.""" + + description = ( + policy.description + or "Applies the repository policy described by this pull request." + ) + change_lines = "\n".join( + f"- `{change.path}`: {change.description}" + + (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") + ) + values = { + "policy_marker": _policy_marker(policy.id), + "policy_head_marker": _policy_head_marker(head_oid), + "policy_id": policy.id, + "policy_description": description, + "policy_trigger": _policy_trigger(policy, changes), + "changes": change_lines, + "failure_section": _failure_section(failure), + } + for key, value in values.items(): + template = template.replace(f"{{{{ {key} }}}}", value) + return template + + +def _failure_section(failure: str | None) -> str: + if failure is None: + return "" + failure = redact_sensitive_text(failure) + return ( + "\n## Automation failure\n\n" + "SCORE Repository Policy Sync could not apply this policy and closed this pull request.\n\n" + f"```text\n{failure}\n```\n" + ) + + +def _pre_commit_failure_comment(failure: str) -> str: + failure = redact_sensitive_text(failure) + return ( + "SCORE Repository Policy Sync created this draft pull request because pre-commit " + "still failed after an automatic formatting-fix retry. Please fix the failure " + "before marking it ready.\n\n" + f"```text\n{failure}\n```" + ) + + +def _policy_trigger(policy: Policy, changes: tuple[Change, ...]) -> str: + paths = tuple(dict.fromkeys(change.path for change in changes)) + targets = ", ".join(f"`{path}`" for path in paths) + reasons: list[str] = [] + file_exists_condition = policy.file_exists_condition + if file_exists_condition is not None: + reasons.append(f"`{file_exists_condition.path}` exists") + file_condition = policy.file_contains_condition + if file_condition is not None: + reasons.append( + f"`{file_condition.path}` matches this policy's file-content condition" + ) + file_any_condition = policy.file_contains_any_condition + if file_any_condition is not None: + paths = ", ".join( + f"`{condition.path}`" for condition in file_any_condition.conditions + ) + reasons.append(f"one of {paths} matches this policy's file-content condition") + bazel_condition = policy.bazel_condition + if bazel_condition is not None: + # Describe both the required group and the alternative group in the PR body. + dependencies = ", ".join( + f"`{dependency}`" + for dependency in bazel_condition.direct_module_dependencies + ) + if dependencies: + reasons.append( + f"`MODULE.bazel` declares the required direct Bazel dependency or dependencies: {dependencies}" + ) + any_dependencies = ", ".join( + f"`{dependency}`" + for dependency in bazel_condition.any_direct_module_dependencies + ) + if any_dependencies: + reasons.append( + f"`MODULE.bazel` declares at least one of these direct Bazel dependencies: {any_dependencies}" + ) + if reasons: + return f"This repository matches this policy because {' and '.join(reasons)}." + return f"This policy applies to configuration in {targets}." diff --git a/repo_policy_sync/models.py b/repo_policy_sync/models.py new file mode 100644 index 0000000..ffecbb5 --- /dev/null +++ b/repo_policy_sync/models.py @@ -0,0 +1,244 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Domain models for policies and evaluated changes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +def policy_branch_slug(policy_id: str) -> str: + """Normalize a policy identifier to the branch-name component it owns.""" + + slug = "".join( + character if character.isalnum() else "-" for character in policy_id.lower() + ) + return "-".join(part for part in slug.split("-") if part) + + +@dataclass(frozen=True) +class BazelDependencyCondition: + """A comparison against one direct bzlmod dependency version.""" + + module_name: str + operator: str + version: tuple[int, int, int] + + +@dataclass(frozen=True) +class BazelCondition: + """A condition on direct bzlmod dependencies.""" + + # The first group is required in full; the second group provides alternatives. + direct_module_dependencies: tuple[str, ...] + any_direct_module_dependencies: tuple[str, ...] = () + any_direct_module_conditions: tuple[BazelDependencyCondition, ...] = () + + +@dataclass(frozen=True) +class FileContainsCondition: + """A condition requiring a file to match a regular expression.""" + + path: Path + pattern: str + + +@dataclass(frozen=True) +class FileContainsAnyCondition: + """A condition requiring at least one file to match a regular expression.""" + + conditions: tuple[FileContainsCondition, ...] + + +@dataclass(frozen=True) +class FileExistsCondition: + """A condition requiring a repository-relative file to exist.""" + + path: Path + + +@dataclass(frozen=True) +class EnsureLine: + path: Path + line: str + replace_lines: tuple[str, ...] + replace_line_globs: tuple[str, ...] = () + rationale: str | None = None + + +@dataclass(frozen=True) +class EnsureNoSuchFile: + path: Path + rationale: str | None = None + + +@dataclass(frozen=True) +class ReplaceRegex: + path: Path + pattern: str + replacement: str + rationale: str | None = None + + +@dataclass(frozen=True) +class EnsureMinimumVersion: + path: Path + minimum_version: str + rationale: str | None = None + + +@dataclass(frozen=True) +class EnsureBazelDependency: + """Ensure a SCORE devcontainer's direct bzlmod dependency exists.""" + + dockerfile: Path + module_file: Path + image: str + module_name: str + rationale: str | None = None + + +@dataclass(frozen=True) +class SynchronizeDevcontainerVersion: + """Keep a SCORE devcontainer image and bzlmod dependency on one version.""" + + dockerfile: Path + module_file: Path + image: str + module_name: str + rationale: str | None = None + + +@dataclass(frozen=True) +class BazelDependencyUpdate: + """A target version, optional module-name migration, and git override.""" + + module_name: str + version: str + replacement_name: str | None = None + optional: bool = False + override: str | None = None + remote: str | None = None + + +@dataclass(frozen=True) +class SynchronizeBazelDependencies: + """Synchronize related bzlmod dependencies and legacy BUILD references.""" + + module_file: Path + dependencies: tuple[BazelDependencyUpdate, ...] + build_file_names: tuple[str, ...] = ("BUILD", "BUILD.bazel") + rationale: str | None = None + + +@dataclass(frozen=True) +class SynchronizeFile: + """Keep a repository file equal to a policy-owned text asset.""" + + path: Path + contents: str + executable: bool = False + rationale: str | None = None + preserve_reusable_workflow_refs: tuple[tuple[str, tuple[int, int, int]], ...] = () + preserve_workflow_content: bool = False + + +@dataclass(frozen=True) +class SynchronizeWorkflow: + """Synchronize a reusable workflow while preserving repository structure.""" + + source: Path + contents: str + reusable_workflow: str + minimum_version: tuple[int, int, int] + required_triggers: tuple[str, ...] + workflow_run_path: Path | None = None + workflow_run_contents: str | None = None + rationale: str | None = None + + +@dataclass(frozen=True) +class MigrateDevcontainerJson: + """Replace an image-based devcontainer config with a Dockerfile.""" + + sources: tuple[Path, ...] + destination: Path + dockerfile: Path + image: str + dockerfile_comment: str | None = None + rationale: str | None = None + copyright_header: str | None = None + copyright_header_organization: str | None = None + + +EnsureOperation = ( + EnsureLine + | EnsureNoSuchFile + | ReplaceRegex + | EnsureMinimumVersion + | EnsureBazelDependency + | SynchronizeDevcontainerVersion + | SynchronizeBazelDependencies + | SynchronizeFile + | SynchronizeWorkflow + | MigrateDevcontainerJson +) + + +@dataclass(frozen=True) +class AfterApplyCommand: + """A command to run after a policy has changed a repository.""" + + command: tuple[str, ...] + when_file_exists: Path + description: str + when_path_changed: Path | None = None + + +@dataclass(frozen=True) +class Policy: + id: str + title: str + description: str | None + bazel_condition: BazelCondition | None + ensure: tuple[EnsureOperation, ...] + after_apply: tuple[AfterApplyCommand, ...] = () + file_exists_condition: FileExistsCondition | None = None + file_contains_condition: FileContainsCondition | None = None + file_contains_any_condition: FileContainsAnyCondition | None = None + + +@dataclass(frozen=True) +class Repository: + name: str + default_branch: str | None + archived: bool = False + + +@dataclass(frozen=True) +class Change: + path: Path + description: str + rationale: str | None = None + + +@dataclass(frozen=True) +class Evaluation: + applies: bool + changes: tuple[Change, ...] + + @property + def compliant(self) -> bool: + return self.applies and not self.changes diff --git a/repo_policy_sync/operations/README.md b/repo_policy_sync/operations/README.md new file mode 100644 index 0000000..d981bcf --- /dev/null +++ b/repo_policy_sync/operations/README.md @@ -0,0 +1,68 @@ + + +# Built-in policy operations + +Policy `ensure` entries use the operation types registered in +[`__init__.py`](__init__.py). This directory contains every operation +available to a policy; operation IDs are part of the policy file format and +must be registered before they can be used. + +Use the catalogue below to choose an operation. The +[policy format reference](../docs/reference/policy-format.md) is the +authoritative source for the complete schema, validation rules, and examples. + +## Operations + +| Operation | Use it for | Main behavior | +| --- | --- | --- | +| `ensure_line` | Keeping one exact line in a text file | Inserts the desired line, removes configured replacements and duplicates, and creates a missing file. | +| `ensure_minimum_version` | Maintaining a simple version file such as `.bazelversion` | Replaces a lower `major.minor.patch` value; equal or higher versions and missing files are compliant. | +| `ensure_no_such_file` | Removing an obsolete file | Deletes an existing file; a missing file is compliant and directories are rejected. | +| `ensure_bazel_dependency` | Adding a direct devcontainer dependency to `MODULE.bazel` | Reads the version from one Dockerfile image tag and adds the dependency when it is missing. | +| `migrate_devcontainer_json` | Converting an image-based devcontainer to a Dockerfile-based one | Migrates supported JSONC configuration, writes the destination, and removes the source while rejecting ambiguous or conflicting files. | +| `replace_regex` | Applying a narrow text substitution | Applies Python `re.sub` to a complete UTF-8 file; missing files and non-matching patterns are compliant. | +| `synchronize_devcontainer_version` | Keeping a devcontainer image and Bazel dependency aligned | Finds one Dockerfile image tag and one direct `bazel_dep`, then upgrades the lower numeric version. | +| `synchronize_bazel_dependencies` | Aligning a set of bzlmod dependencies and BUILD references | Updates configured direct dependencies, renames legacy modules, and manages configured git overrides. | +| `synchronize_file` | Distributing a checked-in policy asset | Copies a policy-local UTF-8 asset to a repository-relative target and can set its executable bit. | +| `synchronize_workflow` | Maintaining a reusable GitHub Actions workflow and its `workflow_run` companion | Finds the unique workflow calling a configured reusable workflow, preserves its name and local jobs, adds required triggers, and synchronizes `workflow_run` names. | + +All operations accept an optional `rationale`. When a change is needed, the +rationale is included with the generated change description. + +The devcontainer migration rejects moving a root `.devcontainer.json` when it +contains path-sensitive `build`, `dockerComposeFile`, `mounts`, or +`workspaceMount` settings whose relative meaning would change. + +## Common rules + +- Operation paths are relative to the repository root unless the policy + format explicitly describes a policy-local source asset. +- Policies are evaluated for applicability first. A policy that does not match + its `when` conditions makes no change. +- Operations are deterministic and idempotent: a compliant repository can be + evaluated repeatedly without producing further changes. +- A policy gathers its changes before applying them, so files created by one + operation are not visible to later operations in that same evaluation. +- Invalid or ambiguous input is rejected during policy loading or evaluation; + operations do not silently guess at an unsupported file format. + +## Related documentation + +- [Policy format reference](../docs/reference/policy-format.md) — complete + operation schemas and semantics. +- [Documentation index](../docs/README.md) — tutorials, how-to guides, + reference pages, and explanations. +- [Run a policy](../docs/how-to/run-a-policy.md) — plan and apply a policy. +- [Bundled policy overview](../policies/README.md) — policies shipped with the + repository and their intended lifecycle. diff --git a/repo_policy_sync/operations/__init__.py b/repo_policy_sync/operations/__init__.py new file mode 100644 index 0000000..cf2bc60 --- /dev/null +++ b/repo_policy_sync/operations/__init__.py @@ -0,0 +1,113 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Built-in policy operations and their explicit dispatch registry.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Protocol + +from ..models import Change, EnsureOperation +from .ensure_bazel_dependency import EnsureBazelDependencyOperation +from .ensure_line import EnsureLineOperation +from .ensure_minimum_version import EnsureMinimumVersionOperation +from .ensure_no_such_file import EnsureNoSuchFileOperation +from .migrate_devcontainer_json import MigrateDevcontainerJsonOperation +from .replace_regex import ReplaceRegexOperation +from .synchronize_devcontainer_version import SynchronizeDevcontainerVersionOperation +from .synchronize_bazel_dependencies import SynchronizeBazelDependenciesOperation +from .synchronize_file import SynchronizeFileOperation +from .synchronize_workflow import SynchronizeWorkflowOperation + + +class OperationHandler(Protocol): + operation_type: str + operation_class: type[Any] + + def parse(self, raw: dict[str, Any], source: Path) -> EnsureOperation: ... + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: ... + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: ... + + +_HANDLERS: tuple[OperationHandler, ...] = ( + EnsureLineOperation(), + EnsureMinimumVersionOperation(), + EnsureNoSuchFileOperation(), + EnsureBazelDependencyOperation(), + MigrateDevcontainerJsonOperation(), + ReplaceRegexOperation(), + SynchronizeDevcontainerVersionOperation(), + SynchronizeBazelDependenciesOperation(), + SynchronizeFileOperation(), + SynchronizeWorkflowOperation(), +) +# Operations that work on a repository root are handled explicitly below; +# path-based operations can be dispatched directly to one target file. +_BY_TYPE = {handler.operation_type: handler for handler in _HANDLERS} + + +def parse_operation(raw: object, source: Path) -> EnsureOperation: + """Parse one operation using the built-in registry.""" + + if not isinstance(raw, dict): + from ..errors import PolicyError + + raise PolicyError(f"policy {source}: each ensure item must be a mapping") + operation_type = raw.get("type") + if not isinstance(operation_type, str) or operation_type not in _BY_TYPE: + from ..errors import PolicyError + + raise PolicyError( + f"policy {source}: unsupported ensure type {operation_type!r}" + ) + return _BY_TYPE[operation_type].parse(raw, source) + + +def describe_changes( + root: Path, operation: EnsureOperation, *, organization: str | None = None +) -> tuple[Change, ...]: + """Describe every path an operation would change.""" + + return _handler_for(operation).describe_changes( + root, operation, organization=organization + ) + + +def apply( + root: Path, operation: EnsureOperation, *, organization: str | None = None +) -> None: + """Apply one operation from a repository root.""" + + _handler_for(operation).apply(root, operation, organization=organization) + + +def _handler_for(operation: EnsureOperation) -> OperationHandler: + for handler in _HANDLERS: + if handler.operation_class is type(operation): + return handler + raise TypeError(f"no operation handler registered for {type(operation).__name__}") diff --git a/repo_policy_sync/operations/_validation.py b/repo_policy_sync/operations/_validation.py new file mode 100644 index 0000000..802195d --- /dev/null +++ b/repo_policy_sync/operations/_validation.py @@ -0,0 +1,109 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Shared YAML validation helpers for built-in operations.""" + +from pathlib import Path +from typing import Any + +from ..errors import PolicyError, RepoPolicySyncError + + +def expect_keys(value: dict[str, Any], allowed: set[str], source: Path) -> None: + unexpected = set(value) - allowed + if unexpected: + raise PolicyError( + f"policy {source}: unexpected fields: {', '.join(sorted(unexpected))}" + ) + + +def safe_relative_path(raw: str, source: Path) -> Path: + path = Path(raw) + if path.is_absolute() or ".." in path.parts or raw in {"", "."}: + raise PolicyError( + f"policy {source}: path must be a non-empty repository-relative path" + ) + return path + + +def validate_repository_path( + root: Path, path: Path, *, allow_final_symlink: bool = False +) -> None: + """Reject repository paths that escape through symlinks before I/O. + + Every component is checked before a caller reads or writes the path. A + final symlink can be allowed for operations that remove the link itself; + its parent is then used for containment checking so the link is never + followed as part of validation. + """ + + root_absolute = root.absolute() + path_absolute = path.absolute() + try: + relative = path_absolute.relative_to(root_absolute) + except ValueError as exc: + raise RepoPolicySyncError( + f"repository path is outside checkout: {path}" + ) from exc + + # Check components individually so an intermediate symlink cannot redirect + # a seemingly repository-relative path before the final containment check. + current = root_absolute + for index, part in enumerate(relative.parts): + current /= part + if current.is_symlink() and not ( + allow_final_symlink and index == len(relative.parts) - 1 + ): + raise RepoPolicySyncError( + f"repository path must not contain a symbolic link: {relative}" + ) + + containment_path = ( + path_absolute.parent + if allow_final_symlink and path_absolute.is_symlink() + else path_absolute + ) + try: + containment_path.resolve(strict=False).relative_to( + root_absolute.resolve(strict=False) + ) + except (OSError, ValueError) as exc: + raise RepoPolicySyncError( + f"repository path resolves outside checkout: {relative}" + ) from exc + + +def required_string(value: dict[str, Any], key: str, source: Path) -> str: + result = value.get(key) + if not isinstance(result, str) or not result.strip(): + raise PolicyError(f"policy {source}: {key} must be a non-empty string") + return result + + +def optional_string(value: dict[str, Any], key: str, source: Path) -> str | None: + result = value.get(key) + if result is None: + return None + if not isinstance(result, str) or not result.strip(): + raise PolicyError(f"policy {source}: {key} must be a non-empty string") + return result + + +def string_list(value: object, name: str, source: Path) -> tuple[str, ...]: + if not isinstance(value, list) or not all( + isinstance(item, str) and item for item in value + ): + raise PolicyError( + f"policy {source}: {name} must be a list of non-empty strings" + ) + return tuple(value) diff --git a/repo_policy_sync/operations/ensure_bazel_dependency.py b/repo_policy_sync/operations/ensure_bazel_dependency.py new file mode 100644 index 0000000..a19e004 --- /dev/null +++ b/repo_policy_sync/operations/ensure_bazel_dependency.py @@ -0,0 +1,182 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Ensure a SCORE devcontainer has a direct bzlmod dependency.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..bazel import starlark_call_ranges +from ..errors import RepoPolicySyncError +from ..models import Change, EnsureBazelDependency, EnsureOperation +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + validate_repository_path, +) + +_NUMERIC_VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") +_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") +_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") + + +@dataclass(frozen=True) +class _Dependency: + version: str + + +class EnsureBazelDependencyOperation: + operation_type = "ensure_bazel_dependency" + operation_class = EnsureBazelDependency + + def parse(self, raw: dict[str, Any], source: Path) -> EnsureBazelDependency: + expect_keys( + raw, + {"type", "dockerfile", "module_file", "image", "module_name", "rationale"}, + source, + ) + return EnsureBazelDependency( + dockerfile=safe_relative_path( + required_string(raw, "dockerfile", source), source + ), + module_file=safe_relative_path( + required_string(raw, "module_file", source), source + ), + image=required_string(raw, "image", source), + module_name=required_string(raw, "module_name", source), + rationale=optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, EnsureBazelDependency) + version = _docker_version(root, operation) + dependency = _module_dependency(root, operation) + if dependency is not None: + return () + return ( + Change( + operation.module_file, + f"add Bazel dependency {operation.module_name!r} at version {version!r}", + operation.rationale, + ), + ) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, EnsureBazelDependency) + version = _docker_version(root, operation) + if _module_dependency(root, operation) is not None: + return + path = root / operation.module_file + text = path.read_text(encoding="utf-8") + separator = "" if text.endswith("\n") else "\n" + blank_line = "" if text.endswith("\n\n") else "\n" + dependency = ( + f"{separator}{blank_line}bazel_dep(\n" + f' name = "{operation.module_name}",\n' + f' version = "{version}",\n' + ")\n" + ) + path.write_text(text + dependency, encoding="utf-8") + + +def _docker_version(root: Path, operation: EnsureBazelDependency) -> str: + path = root / operation.dockerfile + validate_repository_path(root, path) + if not path.is_file(): + raise RepoPolicySyncError(f"{operation.dockerfile} must exist") + text = path.read_text(encoding="utf-8") + matches = list( + re.finditer( + rf"(?m)^\s*FROM\s+{re.escape(operation.image)}:(?P[^\s#]+)[^\r\n]*$", + text, + ) + ) + if len(matches) != 1: + raise RepoPolicySyncError( + f"{operation.dockerfile} must contain exactly one FROM {operation.image}:... instruction" + ) + tag = matches[0].group("tag") + if not tag.startswith("v") or _parse_version(tag[1:]) is None: + raise RepoPolicySyncError( + f"{operation.dockerfile} must use {operation.image}:vX.Y.Z, found {tag!r}" + ) + return tag[1:] + + +def _module_dependency( + root: Path, operation: EnsureBazelDependency +) -> _Dependency | None: + path = root / operation.module_file + validate_repository_path(root, path) + if not path.is_file(): + raise RepoPolicySyncError(f"{operation.module_file} must exist") + text = path.read_text(encoding="utf-8") + calls = [] + for start, end in starlark_call_ranges(text, "bazel_dep"): + # A commented dependency is documentation, not an installed direct + # dependency, so only ranges returned from active source are examined. + body = text[start:end] + name_matches = list(_NAME_ARGUMENT.finditer(body)) + if any( + name_match.group(1) == operation.module_name for name_match in name_matches + ): + if len(name_matches) != 1: + raise RepoPolicySyncError( + f"{operation.module_file} bazel_dep for {operation.module_name!r} " + "must declare name exactly once" + ) + calls.append((start, end)) + if len(calls) > 1: + raise RepoPolicySyncError( + f"{operation.module_file} must contain at most one bazel_dep for {operation.module_name!r}" + ) + if not calls: + return None + version_matches = list(_VERSION_ARGUMENT.finditer(text[calls[0][0] : calls[0][1]])) + if not version_matches: + raise RepoPolicySyncError( + f'{operation.module_file} bazel_dep for {operation.module_name!r} must declare version = "X.Y.Z"' + ) + if len(version_matches) != 1: + raise RepoPolicySyncError( + f"{operation.module_file} bazel_dep for {operation.module_name!r} must declare version exactly once" + ) + version = version_matches[0].group(2) + if _parse_version(version) is None: + raise RepoPolicySyncError( + f"{operation.module_file} bazel_dep for {operation.module_name!r} must use X.Y.Z, found {version!r}" + ) + return _Dependency(version) + + +def _parse_version(value: str) -> tuple[int, int, int] | None: + match = _NUMERIC_VERSION.fullmatch(value) + return tuple(int(component) for component in match.groups()) if match else None diff --git a/repo_policy_sync/operations/ensure_line.py b/repo_policy_sync/operations/ensure_line.py new file mode 100644 index 0000000..c8d1717 --- /dev/null +++ b/repo_policy_sync/operations/ensure_line.py @@ -0,0 +1,139 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""The ensure_line operation.""" + +from fnmatch import fnmatchcase +from pathlib import Path +from typing import Any + +from ..errors import RepoPolicySyncError +from ..models import Change, EnsureLine, EnsureOperation +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + string_list, + validate_repository_path, +) + + +class EnsureLineOperation: + operation_type = "ensure_line" + operation_class = EnsureLine + + def parse(self, raw: dict[str, Any], source: Path) -> EnsureLine: + expect_keys( + raw, + { + "type", + "path", + "line", + "replace_lines", + "replace_line_globs", + "rationale", + }, + source, + ) + return EnsureLine( + path=safe_relative_path(required_string(raw, "path", source), source), + line=required_string(raw, "line", source), + replace_lines=string_list( + raw.get("replace_lines", []), "replace_lines", source + ), + replace_line_globs=string_list( + raw.get("replace_line_globs", []), "replace_line_globs", source + ), + rationale=optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, EnsureLine) + path = root / operation.path + validate_repository_path(root, path) + _validate_target(path, operation) + lines = _read_lines(path) + normalized = _normalized_lines(lines, operation) + if normalized == lines: + return () + desired_count = sum(line == operation.line for line in lines) + obsolete = [ + line + for line in lines + if line != operation.line and _matches_replacement(line, operation) + ] + if obsolete: + description = f"replace {', '.join(repr(line) for line in dict.fromkeys(obsolete))} with {operation.line!r}" + elif desired_count == 0: + description = f"add {operation.line!r}" + elif desired_count > 1: + description = f"remove duplicate {operation.line!r} entries" + else: + description = f"normalize {operation.line!r}" + return (Change(operation.path, description, operation.rationale),) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, EnsureLine) + path = root / operation.path + validate_repository_path(root, path) + _validate_target(path, operation) + normalized = _normalized_lines(_read_lines(path), operation) + if normalized == _read_lines(path): + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(normalized) + "\n", encoding="utf-8") + + +def _normalized_lines(lines: list[str], operation: EnsureLine) -> list[str]: + indexes = [ + index + for index, line in enumerate(lines) + if line == operation.line or _matches_replacement(line, operation) + ] + if not indexes: + return [*lines, operation.line] + normalized = [ + line + for line in lines + if line != operation.line and not _matches_replacement(line, operation) + ] + normalized.insert(indexes[0], operation.line) + return normalized + + +def _matches_replacement(line: str, operation: EnsureLine) -> bool: + return line in operation.replace_lines or any( + fnmatchcase(line, pattern) for pattern in operation.replace_line_globs + ) + + +def _read_lines(path: Path) -> list[str]: + return path.read_text(encoding="utf-8").splitlines() if path.exists() else [] + + +def _validate_target(path: Path, operation: EnsureLine) -> None: + if path.exists() and not path.is_file(): + raise RepoPolicySyncError(f"{operation.path} must be a file") diff --git a/repo_policy_sync/operations/ensure_minimum_version.py b/repo_policy_sync/operations/ensure_minimum_version.py new file mode 100644 index 0000000..2fe3d70 --- /dev/null +++ b/repo_policy_sync/operations/ensure_minimum_version.py @@ -0,0 +1,111 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""The ensure_minimum_version operation.""" + +import re +from pathlib import Path +from typing import Any + +from ..errors import PolicyError, RepoPolicySyncError +from ..models import Change, EnsureMinimumVersion, EnsureOperation +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + validate_repository_path, +) + +_VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") + + +class EnsureMinimumVersionOperation: + operation_type = "ensure_minimum_version" + operation_class = EnsureMinimumVersion + + def parse(self, raw: dict[str, Any], source: Path) -> EnsureMinimumVersion: + expect_keys(raw, {"type", "path", "minimum_version", "rationale"}, source) + minimum_version = required_string(raw, "minimum_version", source) + if _parse_version(minimum_version) is None: + raise PolicyError( + f"policy {source}: minimum_version must be a numeric major.minor.patch version" + ) + return EnsureMinimumVersion( + safe_relative_path(required_string(raw, "path", source), source), + minimum_version, + optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, EnsureMinimumVersion) + path = root / operation.path + validate_repository_path(root, path) + current_version = _read_version(path, operation) + if current_version is None or current_version >= _required_version(operation): + return () + return ( + Change( + operation.path, + f"upgrade from {path.read_text(encoding='utf-8').strip()!r} to {operation.minimum_version!r}", + operation.rationale, + ), + ) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, EnsureMinimumVersion) + path = root / operation.path + validate_repository_path(root, path) + current_version = _read_version(path, operation) + if current_version is None or current_version >= _required_version(operation): + return + path.write_text(f"{operation.minimum_version}\n", encoding="utf-8") + + +def _read_version( + path: Path, operation: EnsureMinimumVersion +) -> tuple[int, int, int] | None: + if path.exists() and not path.is_file(): + raise RepoPolicySyncError(f"{operation.path} must be a file") + if not path.is_file(): + return None + version = path.read_text(encoding="utf-8").strip() + parsed = _parse_version(version) + if parsed is None: + raise RepoPolicySyncError( + f"{operation.path} must contain a numeric major.minor.patch version, found {version!r}" + ) + return parsed + + +def _required_version(operation: EnsureMinimumVersion) -> tuple[int, int, int]: + parsed = _parse_version(operation.minimum_version) + assert parsed is not None + return parsed + + +def _parse_version(value: str) -> tuple[int, int, int] | None: + match = _VERSION.fullmatch(value) + return tuple(int(component) for component in match.groups()) if match else None diff --git a/repo_policy_sync/operations/ensure_no_such_file.py b/repo_policy_sync/operations/ensure_no_such_file.py new file mode 100644 index 0000000..6cf6e3f --- /dev/null +++ b/repo_policy_sync/operations/ensure_no_such_file.py @@ -0,0 +1,73 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""The ensure_no_such_file operation.""" + +from pathlib import Path +from typing import Any + +from ..errors import RepoPolicySyncError +from ..models import Change, EnsureNoSuchFile, EnsureOperation +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + validate_repository_path, +) + + +class EnsureNoSuchFileOperation: + operation_type = "ensure_no_such_file" + operation_class = EnsureNoSuchFile + + def parse(self, raw: dict[str, Any], source: Path) -> EnsureNoSuchFile: + expect_keys(raw, {"type", "path", "rationale"}, source) + return EnsureNoSuchFile( + safe_relative_path(required_string(raw, "path", source), source), + optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, EnsureNoSuchFile) + path = root / operation.path + validate_repository_path(root, path, allow_final_symlink=True) + if not path.is_symlink() and path.is_dir(): + raise RepoPolicySyncError(f"refusing to remove directory {operation.path}") + return ( + (Change(operation.path, "remove file", operation.rationale),) + if path.exists() or path.is_symlink() + else () + ) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, EnsureNoSuchFile) + path = root / operation.path + validate_repository_path(root, path, allow_final_symlink=True) + if not path.exists() and not path.is_symlink(): + return + if not path.is_symlink() and path.is_dir(): + raise RepoPolicySyncError(f"refusing to remove directory {operation.path}") + path.unlink() diff --git a/repo_policy_sync/operations/migrate_devcontainer_json.py b/repo_policy_sync/operations/migrate_devcontainer_json.py new file mode 100644 index 0000000..625a6eb --- /dev/null +++ b/repo_policy_sync/operations/migrate_devcontainer_json.py @@ -0,0 +1,326 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Migrate an image-based devcontainer configuration to a Dockerfile.""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from typing import Any + +from ..errors import PolicyError, RepoPolicySyncError +from ..models import Change, EnsureOperation, MigrateDevcontainerJson +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + string_list, + validate_repository_path, +) + +_VERSION = re.compile(r"v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z") +_IMAGE_PROPERTY = re.compile( + r'(?m)(?P^[ \t]*|(?<=[{,])[ \t]*)"image"\s*:\s*"' + r'(?P[^"\\]*(?:\\.[^"\\]*)*)"(?P,?)' +) + + +class MigrateDevcontainerJsonOperation: + operation_type = "migrate_devcontainer_json" + operation_class = MigrateDevcontainerJson + + def parse(self, raw: dict[str, Any], source: Path) -> MigrateDevcontainerJson: + expect_keys( + raw, + { + "type", + "sources", + "destination", + "dockerfile", + "image", + "dockerfile_comment", + "rationale", + "copyright_header_source", + "copyright_header_organization", + }, + source, + ) + copyright_header_source = raw.get("copyright_header_source") + copyright_header = None + if copyright_header_source is not None: + header_path = source.parent / safe_relative_path( + required_string(raw, "copyright_header_source", source), source + ) + try: + copyright_header = header_path.read_text(encoding="utf-8") + except OSError as exc: + raise PolicyError( + f"policy {source}: could not read copyright header source " + f"{copyright_header_source}: {exc}" + ) from exc + except UnicodeError as exc: + raise PolicyError( + f"policy {source}: copyright header source must be UTF-8: " + f"{copyright_header_source}" + ) from exc + copyright_header_organization = optional_string( + raw, "copyright_header_organization", source + ) + if copyright_header_organization is not None and copyright_header is None: + raise PolicyError( + f"policy {source}: copyright_header_organization requires " + "copyright_header_source" + ) + return MigrateDevcontainerJson( + sources=tuple( + safe_relative_path(item, source) + for item in string_list(raw.get("sources"), "sources", source) + ), + destination=safe_relative_path( + required_string(raw, "destination", source), source + ), + dockerfile=safe_relative_path( + required_string(raw, "dockerfile", source), source + ), + image=required_string(raw, "image", source), + dockerfile_comment=optional_string(raw, "dockerfile_comment", source), + rationale=optional_string(raw, "rationale", source), + copyright_header=copyright_header, + copyright_header_organization=copyright_header_organization, + ) + + def describe_changes( + self, root: Path, operation: EnsureOperation, *, organization: str | None = None + ) -> tuple[Change, ...]: + assert isinstance(operation, MigrateDevcontainerJson) + source_relative, source = _find_source(root, operation) + if source is None: + return () + dockerfile = root / operation.dockerfile + destination = root / operation.destination + validate_repository_path(root, dockerfile) + validate_repository_path(root, destination) + migration = _migration_contents( + source, source_relative, operation, organization + ) + if migration is None: + return () + dockerfile_contents, destination_contents = migration + _validate_target(dockerfile, operation) + _validate_destination(destination, operation) + changes: list[Change] = [] + if not dockerfile.exists(): + changes.append( + Change(operation.dockerfile, "add Dockerfile", operation.rationale) + ) + elif dockerfile.read_text(encoding="utf-8") != dockerfile_contents: + raise RepoPolicySyncError( + f"refusing to overwrite existing {operation.dockerfile} during migration" + ) + if not destination.exists(): + changes.append( + Change( + operation.destination, + "add devcontainer configuration", + operation.rationale, + ) + ) + elif ( + source != destination + and destination.read_text(encoding="utf-8") != destination_contents + ): + raise RepoPolicySyncError( + f"refusing to overwrite existing {operation.destination} during migration" + ) + elif ( + source == destination + and source.read_text(encoding="utf-8") != destination_contents + ): + changes.append( + Change( + operation.destination, + "configure the devcontainer to build the Dockerfile", + operation.rationale, + ) + ) + if source != destination: + changes.append( + Change( + source_relative, + "move devcontainer configuration", + operation.rationale, + ) + ) + return tuple(changes) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, MigrateDevcontainerJson) + source_relative, source = _find_source(root, operation) + if source is None or source_relative is None: + return + dockerfile = root / operation.dockerfile + destination = root / operation.destination + validate_repository_path(root, dockerfile) + validate_repository_path(root, destination) + migration = _migration_contents( + source, source_relative, operation, organization + ) + if migration is None: + return + dockerfile_contents, destination_contents = migration + _validate_target(dockerfile, operation) + _validate_destination(destination, operation) + if ( + dockerfile.exists() + and dockerfile.read_text(encoding="utf-8") != dockerfile_contents + ): + raise RepoPolicySyncError( + f"refusing to overwrite existing {operation.dockerfile} during migration" + ) + if ( + destination.exists() + and source != destination + and destination.read_text(encoding="utf-8") != destination_contents + ): + raise RepoPolicySyncError( + f"refusing to overwrite existing {operation.destination} during migration" + ) + if not dockerfile.exists(): + dockerfile.parent.mkdir(parents=True, exist_ok=True) + dockerfile.write_text(dockerfile_contents, encoding="utf-8") + if not destination.exists() or source == destination: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(destination_contents, encoding="utf-8") + if source != destination: + source.unlink() + + +def _find_source( + root: Path, operation: MigrateDevcontainerJson +) -> tuple[Path | None, Path | None]: + matches: list[tuple[Path, Path]] = [] + for path in operation.sources: + candidate = root / path + validate_repository_path(root, candidate) + if candidate.exists(): + matches.append((path, candidate)) + if len(matches) > 1: + paths = ", ".join(str(path) for path, _ in matches) + raise RepoPolicySyncError( + f"only one devcontainer configuration may exist; found {paths}" + ) + return matches[0] if matches else (None, None) + + +def _migration_contents( + source: Path, + source_relative: Path, + operation: MigrateDevcontainerJson, + organization: str | None, +) -> tuple[str, str] | None: + if not source.is_file(): + raise RepoPolicySyncError(f"{source} must be a file") + text = source.read_text(encoding="utf-8") + try: + configuration = json.loads(_strip_jsonc(text)) + except json.JSONDecodeError as exc: + raise RepoPolicySyncError( + f"{source} must contain valid JSONC: {exc.msg}" + ) from exc + if not isinstance(configuration, dict): + raise RepoPolicySyncError(f"{source} must contain a JSON object") + if source_relative.parent == Path(".") and operation.destination != source_relative: + # Moving a root config into .devcontainer changes the base directory for + # these fields. Refuse the ambiguous case instead of guessing rewrites. + location_sensitive_keys = { + "build", + "dockerComposeFile", + "mounts", + "workspaceMount", + } + affected_keys = sorted(location_sensitive_keys.intersection(configuration)) + if affected_keys: + keys = ", ".join(affected_keys) + raise RepoPolicySyncError( + f"refusing to move root {source_relative}: relative paths in {keys} " + "would change meaning" + ) + image = configuration.get("image") + prefix = f"{operation.image}:" + if not isinstance(image, str) or not image.startswith(prefix): + return None + tag = image.removeprefix(prefix) + if not tag or _VERSION.fullmatch(tag) is None: + raise RepoPolicySyncError( + f"{source} must use {operation.image}:vX.Y.Z, found {tag!r}" + ) + matches = [ + match + for match in _IMAGE_PROPERTY.finditer(text) + if match.group("image") == image + ] + if len(matches) != 1: + raise RepoPolicySyncError( + f"{source} must contain exactly one top-level image property" + ) + match = matches[0] + indent = match.group("indent") + dockerfile = json.dumps( + os.path.relpath(operation.dockerfile, operation.destination.parent).replace( + os.sep, "/" + ) + ) + replacement = ( + f'{indent}"build": {{\n' + f'{indent} "dockerfile": {dockerfile}\n' + f"{indent}}}{match.group('comma')}" + ) + destination_contents = text[: match.start()] + replacement + text[match.end() :] + copyright_header = ( + operation.copyright_header + if operation.copyright_header_organization == organization + else "" + ) + prefix = f"{copyright_header}\n" if copyright_header else "" + comment = ( + f"{operation.dockerfile_comment}\n" if operation.dockerfile_comment else "" + ) + dockerfile_contents = f"{prefix}{comment}FROM {operation.image}:{tag}\n" + return dockerfile_contents, destination_contents + + +def _strip_jsonc(text: str) -> str: + """Remove simple full-line comments and trailing commas.""" + + without_comments = re.sub(r"(?m)^[ \t]*//[^\r\n]*(?:\r?\n|$)", "", text) + return re.sub(r",\s*([}\]])", r"\1", without_comments) + + +def _validate_target(path: Path, operation: MigrateDevcontainerJson) -> None: + if path.exists() and not path.is_file(): + raise RepoPolicySyncError(f"{operation.dockerfile} must not be a directory") + + +def _validate_destination(path: Path, operation: MigrateDevcontainerJson) -> None: + if path.exists() and not path.is_file(): + raise RepoPolicySyncError(f"{operation.destination} must not be a directory") diff --git a/repo_policy_sync/operations/replace_regex.py b/repo_policy_sync/operations/replace_regex.py new file mode 100644 index 0000000..4eaeb5c --- /dev/null +++ b/repo_policy_sync/operations/replace_regex.py @@ -0,0 +1,95 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""The replace_regex operation.""" + +import re +from pathlib import Path +from typing import Any + +from ..errors import PolicyError, RepoPolicySyncError +from ..models import Change, EnsureOperation, ReplaceRegex +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + validate_repository_path, +) + + +class ReplaceRegexOperation: + operation_type = "replace_regex" + operation_class = ReplaceRegex + + def parse(self, raw: dict[str, Any], source: Path) -> ReplaceRegex: + expect_keys( + raw, {"type", "path", "pattern", "replacement", "rationale"}, source + ) + pattern = required_string(raw, "pattern", source) + replacement = required_string(raw, "replacement", source) + try: + re.compile(pattern).sub(replacement, "") + except re.error as exc: + raise PolicyError( + f"policy {source}: invalid replace_regex pattern or replacement: {exc}" + ) from exc + return ReplaceRegex( + safe_relative_path(required_string(raw, "path", source), source), + pattern, + replacement, + optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, ReplaceRegex) + path = root / operation.path + validate_repository_path(root, path) + _validate_target(path, operation) + if not path.is_file(): + return () + text = path.read_text(encoding="utf-8") + return ( + (Change(operation.path, "replace matching text", operation.rationale),) + if re.sub(operation.pattern, operation.replacement, text) != text + else () + ) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, ReplaceRegex) + path = root / operation.path + validate_repository_path(root, path) + _validate_target(path, operation) + if not path.is_file(): + return + text = path.read_text(encoding="utf-8") + replaced = re.sub(operation.pattern, operation.replacement, text) + if replaced != text: + path.write_text(replaced, encoding="utf-8") + + +def _validate_target(path: Path, operation: ReplaceRegex) -> None: + if path.exists() and not path.is_file(): + raise RepoPolicySyncError(f"{operation.path} must be a file") diff --git a/repo_policy_sync/operations/synchronize_bazel_dependencies.py b/repo_policy_sync/operations/synchronize_bazel_dependencies.py new file mode 100644 index 0000000..32e621e --- /dev/null +++ b/repo_policy_sync/operations/synchronize_bazel_dependencies.py @@ -0,0 +1,542 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Synchronize related bzlmod dependencies and legacy BUILD references.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..bazel import parse_bazel_version, starlark_call_ranges +from ..errors import PolicyError, RepoPolicySyncError +from ..models import ( + BazelDependencyUpdate, + Change, + EnsureOperation, + SynchronizeBazelDependencies, +) +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + string_list, + validate_repository_path, +) + +# Keep the complete call text so argument values can be located exactly. +_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*([\"'])([^\"']+)\1") +_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") +_MODULE_NAME_ARGUMENT = re.compile(r"\bmodule_name\s*=\s*([\"'])([^\"']+)\1") +_COMMIT_ARGUMENT = re.compile(r"\bcommit\s*=\s*([\"'])([^\"']*)\1") +_REMOTE_ARGUMENT = re.compile(r"\bremote\s*=\s*([\"'])([^\"']*)\1") + + +@dataclass(frozen=True) +class _DependencyLocation: + # These are absolute offsets into MODULE.bazel, not offsets inside bazel_dep. + # Absolute offsets let the caller update several fields without reparsing text. + name: str + version: tuple[int, int, int] + name_start: int + name_end: int + version_start: int + version_end: int + + +@dataclass(frozen=True) +class _GitOverrideLocation: + # These are absolute offsets into MODULE.bazel. + module_name: str + commit: str + module_name_start: int + module_name_end: int + commit_start: int + commit_end: int + remote: str | None + remote_start: int | None + remote_end: int | None + remote_insertion: int + remote_insertion_prefix: str + + +class SynchronizeBazelDependenciesOperation: + operation_type = "synchronize_bazel_dependencies" + operation_class = SynchronizeBazelDependencies + + def parse(self, raw: dict[str, Any], source: Path) -> SynchronizeBazelDependencies: + expect_keys( + raw, + {"type", "module_file", "dependencies", "build_file_names", "rationale"}, + source, + ) + dependency_items = raw.get("dependencies") + if not isinstance(dependency_items, list) or not dependency_items: + raise PolicyError(f"policy {source}: dependencies must be a non-empty list") + dependencies = tuple( + _parse_dependency(item, source, index) + for index, item in enumerate(dependency_items) + ) + names = [ + name + for dependency in dependencies + for name in _dependency_names(dependency) + ] + if len(names) != len(set(names)): + raise PolicyError( + f"policy {source}: dependencies must not contain duplicate module names" + ) + + build_file_names = string_list( + raw.get("build_file_names", ["BUILD", "BUILD.bazel"]), + "build_file_names", + source, + ) + if any( + Path(name).name != name or Path(name).is_absolute() + for name in build_file_names + ): + raise PolicyError( + f"policy {source}: build_file_names must contain file names only" + ) + if len(build_file_names) != len(set(build_file_names)): + raise PolicyError( + f"policy {source}: build_file_names must not contain duplicates" + ) + return SynchronizeBazelDependencies( + module_file=safe_relative_path( + required_string(raw, "module_file", source), source + ), + dependencies=dependencies, + build_file_names=build_file_names, + rationale=optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, SynchronizeBazelDependencies) + module_path = root / operation.module_file + validate_repository_path(root, module_path) + # Validation happens while collecting replacements, even when no text changes. + replacements, locations = _module_replacements(module_path, operation) + changes: list[Change] = [] + if replacements: + changes.append( + Change( + operation.module_file, + "synchronize Bazel dependency versions and module names", + operation.rationale, + ) + ) + build_pairs = _build_reference_pairs(operation, locations) + for path in _build_files(root, operation): + text = path.read_text(encoding="utf-8") + if _replace_build_references(text, build_pairs) != text: + changes.append( + Change( + path.relative_to(root), + _build_reference_description(build_pairs), + operation.rationale, + ) + ) + return tuple(changes) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, SynchronizeBazelDependencies) + module_path = root / operation.module_file + validate_repository_path(root, module_path) + replacements, locations = _module_replacements(module_path, operation) + if replacements: + text = module_path.read_text(encoding="utf-8") + # Apply from the end so earlier offsets stay valid after each edit. + for start, end, replacement in sorted(replacements, reverse=True): + text = text[:start] + replacement + text[end:] + module_path.write_text(text, encoding="utf-8") + build_pairs = _build_reference_pairs(operation, locations) + for path in _build_files(root, operation): + text = path.read_text(encoding="utf-8") + replaced = _replace_build_references(text, build_pairs) + if replaced != text: + path.write_text(replaced, encoding="utf-8") + + +def _parse_dependency(raw: object, source: Path, index: int) -> BazelDependencyUpdate: + if not isinstance(raw, dict): + raise PolicyError(f"policy {source}: dependencies[{index}] must be a mapping") + expect_keys( + raw, + {"name", "version", "replacement_name", "optional", "override", "remote"}, + source, + ) + version = required_string(raw, "version", source) + if parse_bazel_version(version) is None: + raise PolicyError( + f"policy {source}: dependencies[{index}].version must be a numeric major.minor.patch version" + ) + replacement_name = raw.get("replacement_name") + if replacement_name is not None and ( + not isinstance(replacement_name, str) or not replacement_name.strip() + ): + raise PolicyError( + f"policy {source}: replacement_name must be a non-empty string" + ) + name = required_string(raw, "name", source) + if replacement_name == name: + raise PolicyError(f"policy {source}: replacement_name must differ from name") + optional = raw.get("optional", False) + if not isinstance(optional, bool): + raise PolicyError( + f"policy {source}: dependencies[{index}].optional must be a boolean" + ) + override = optional_string(raw, "override", source) + remote = optional_string(raw, "remote", source) + if override is None and remote is not None: + raise PolicyError(f"policy {source}: remote requires override") + if override is not None and remote is None: + raise PolicyError(f"policy {source}: override requires remote") + return BazelDependencyUpdate( + name, version, replacement_name, optional, override, remote + ) + + +def _dependency_names(dependency: BazelDependencyUpdate) -> tuple[str, ...]: + return (dependency.module_name,) + ( + (dependency.replacement_name,) + if dependency.replacement_name is not None + else () + ) + + +def _module_replacements( + path: Path, operation: SynchronizeBazelDependencies +) -> tuple[list[tuple[int, int, str]], dict[str, _DependencyLocation]]: + if not path.is_file(): + raise RepoPolicySyncError(f"{operation.module_file} must exist") + text = path.read_text(encoding="utf-8") + locations = _module_locations(text, operation) + replacements: list[tuple[int, int, str]] = [] + for dependency in operation.dependencies: + location = locations.get(dependency.module_name) + if location is None: + continue + is_legacy_name = ( + location.name == dependency.module_name + and dependency.replacement_name is not None + ) + if is_legacy_name: + assert dependency.replacement_name is not None + replacements.append( + (location.name_start, location.name_end, dependency.replacement_name) + ) + replacements.append( + (location.version_start, location.version_end, dependency.version) + ) + else: + # Existing module names are only upgraded; newer versions are preserved. + target_version = parse_bazel_version(dependency.version) + assert target_version is not None + if location.version < target_version: + replacements.append( + (location.version_start, location.version_end, dependency.version) + ) + replacements.extend(_git_override_replacements(text, operation, locations)) + return replacements, locations + + +def _build_reference_pairs( + operation: SynchronizeBazelDependencies, + locations: dict[str, _DependencyLocation], +) -> tuple[tuple[str, str], ...]: + """Return active legacy-to-current module renames for BUILD files.""" + + return tuple( + (dependency.module_name, dependency.replacement_name) + for dependency in operation.dependencies + if dependency.replacement_name is not None + and dependency.module_name in locations + ) + + +def _replace_build_references(text: str, pairs: tuple[tuple[str, str], ...]) -> str: + if not pairs: + return text + replacements = dict(pairs) + names = sorted(replacements, key=lambda name: (-len(name), name)) + # Only external labels have @ or @@. Requiring that marker avoids changing + # local target names that happen to contain the old module name. + pattern = re.compile( + r"(?P@@?)(?P" + + "|".join(re.escape(name) for name in names) + + r")(?=//)" + ) + return pattern.sub( + lambda match: match.group("prefix") + replacements[match.group("module")], + text, + ) + + +def _build_reference_description(pairs: tuple[tuple[str, str], ...]) -> str: + if len(pairs) == 1: + old_name, new_name = pairs[0] + return f"replace {old_name!r} with {new_name!r} in BUILD files" + return "replace legacy Bazel module references in BUILD files" + + +def _git_override_replacements( + text: str, + operation: SynchronizeBazelDependencies, + locations: dict[str, _DependencyLocation], +) -> list[tuple[int, int, str]]: + overrides = _git_override_locations(text, operation) + replacements: list[tuple[int, int, str]] = [] + missing: list[tuple[str, str, str]] = [] + for dependency in operation.dependencies: + if dependency.override is None: + continue + if dependency.remote is None: + raise RepoPolicySyncError( + f"{operation.module_file} git override for {dependency.module_name!r} " + "must define remote" + ) + location = locations.get(dependency.module_name) + # Optional dependencies absent from a repository are skipped, including + # their override. Required dependencies have already been validated by + # _module_locations. + if location is None: + continue + target_version = parse_bazel_version(dependency.version) + assert target_version is not None + if location.version > target_version: + # A git override belongs to the configured baseline. Preserve a + # newer released dependency and its existing source pin. + continue + final_name = ( + dependency.replacement_name + if location.name == dependency.module_name + and dependency.replacement_name is not None + else location.name + ) + matching_names = [ + name for name in _dependency_names(dependency) if name in overrides + ] + if len(matching_names) > 1: + raise RepoPolicySyncError( + f"{operation.module_file} must contain at most one git_override for " + f"{final_name!r}" + ) + if not matching_names: + missing.append((final_name, dependency.override, dependency.remote)) + continue + override = overrides[matching_names[0]] + if override.module_name != final_name: + replacements.append( + (override.module_name_start, override.module_name_end, final_name) + ) + if override.commit != dependency.override: + replacements.append( + (override.commit_start, override.commit_end, dependency.override) + ) + if override.remote is None: + replacements.append( + ( + override.remote_insertion, + override.remote_insertion, + f'{override.remote_insertion_prefix} remote = "{dependency.remote}",\n', + ) + ) + elif override.remote != dependency.remote: + assert override.remote_start is not None + assert override.remote_end is not None + replacements.append( + (override.remote_start, override.remote_end, dependency.remote) + ) + if missing: + separator = "" if text.endswith("\n\n") else "\n" + blocks = "\n".join( + "\n".join( + ( + "git_override(", + f' module_name = "{module_name}",', + f' commit = "{commit}",', + f' remote = "{remote}",', + ")", + ) + ) + for module_name, commit, remote in missing + ) + replacements.append((len(text), len(text), f"{separator}{blocks}\n")) + return replacements + + +def _git_override_locations( + text: str, operation: SynchronizeBazelDependencies +) -> dict[str, _GitOverrideLocation]: + configured_names = { + name + for dependency in operation.dependencies + if dependency.override is not None + for name in _dependency_names(dependency) + } + locations: dict[str, _GitOverrideLocation] = {} + for body_start, body_end in starlark_call_ranges(text, "git_override"): + # Commented examples are not active overrides and must remain unchanged. + body = text[body_start:body_end] + module_name_matches = list(_MODULE_NAME_ARGUMENT.finditer(body)) + matching_names = [ + match for match in module_name_matches if match.group(2) in configured_names + ] + if not matching_names: + continue + if len(module_name_matches) != 1: + raise RepoPolicySyncError( + f"{operation.module_file} git_override must declare module_name exactly once " + f"for {matching_names[0].group(2)!r}" + ) + module_name_match = matching_names[0] + module_name = module_name_match.group(2) + if module_name in locations: + raise RepoPolicySyncError( + f"{operation.module_file} must contain at most one git_override for " + f"{module_name!r}" + ) + commit_matches = list(_COMMIT_ARGUMENT.finditer(body)) + if len(commit_matches) != 1: + raise RepoPolicySyncError( + f"{operation.module_file} git_override for {module_name!r} must declare " + "commit exactly once" + ) + commit_match = commit_matches[0] + remote_matches = list(_REMOTE_ARGUMENT.finditer(body)) + if len(remote_matches) > 1: + raise RepoPolicySyncError( + f"{operation.module_file} git_override for {module_name!r} must declare " + "remote at most once" + ) + remote_match = remote_matches[0] if remote_matches else None + locations[module_name] = _GitOverrideLocation( + module_name=module_name, + commit=commit_match.group(2), + module_name_start=body_start + module_name_match.start(2), + module_name_end=body_start + module_name_match.end(2), + commit_start=body_start + commit_match.start(2), + commit_end=body_start + commit_match.end(2), + remote=remote_match.group(2) if remote_match else None, + remote_start=(body_start + remote_match.start(2)) if remote_match else None, + remote_end=(body_start + remote_match.end(2)) if remote_match else None, + remote_insertion=body_end, + remote_insertion_prefix="" if body.endswith("\n") else "\n", + ) + return locations + + +def _module_locations( + text: str, operation: SynchronizeBazelDependencies +) -> dict[str, _DependencyLocation]: + locations: dict[str, _DependencyLocation] = {} + configured_names = { + name + for dependency in operation.dependencies + for name in _dependency_names(dependency) + } + for body_start, body_end in starlark_call_ranges(text, "bazel_dep"): + # Only active bazel_dep calls participate in synchronization; comments + # often document an old dependency and must not affect the result. + body = text[body_start:body_end] + name_matches = list(_NAME_ARGUMENT.finditer(body)) + matching_names = [ + match for match in name_matches if match.group(2) in configured_names + ] + if not matching_names: + continue + # Only configured direct dependencies need strict validation. Other Bazel + # calls are left untouched and can use a different declaration style. + if len(name_matches) != 1: + raise RepoPolicySyncError( + f"{operation.module_file} bazel_dep must declare name exactly once for " + f"{matching_names[0].group(2)!r}" + ) + name_match = matching_names[0] + name = name_match.group(2) + if name in locations: + raise RepoPolicySyncError( + f"{operation.module_file} must contain at most one bazel_dep for {name!r}" + ) + version_matches = list(_VERSION_ARGUMENT.finditer(body)) + if len(version_matches) != 1: + raise RepoPolicySyncError( + f"{operation.module_file} bazel_dep for {name!r} must declare version exactly once" + ) + version_match = version_matches[0] + version_text = version_match.group(2) + version = parse_bazel_version(version_text) + if version is None: + raise RepoPolicySyncError( + f"{operation.module_file} bazel_dep for {name!r} must use X.Y.Z, found {version_text!r}" + ) + locations[name] = _DependencyLocation( + name=name, + version=version, + name_start=body_start + name_match.start(2), + name_end=body_start + name_match.end(2), + version_start=body_start + version_match.start(2), + version_end=body_start + version_match.end(2), + ) + for dependency in operation.dependencies: + # A migration may find either its old name or its new name, but never both. + configured = [ + name for name in _dependency_names(dependency) if name in locations + ] + if not configured and dependency.optional: + continue + if len(configured) != 1: + names = " or ".join(repr(name) for name in _dependency_names(dependency)) + raise RepoPolicySyncError( + f"{operation.module_file} must contain exactly one bazel_dep for {names}" + ) + location = locations[configured[0]] + locations[dependency.module_name] = location + return locations + + +def _build_files( + root: Path, operation: SynchronizeBazelDependencies +) -> tuple[Path, ...]: + # BUILD files are selected by basename because Bazel allows them in every package. + paths: list[Path] = [] + for path in sorted(root.rglob("*")): + relative = path.relative_to(root) + if ".git" in relative.parts: + continue + # A final symlink is not a BUILD file managed by this operation. Check + # its parent for containment, then skip it without following the link. + if path.is_symlink(): + validate_repository_path(root, path, allow_final_symlink=True) + continue + validate_repository_path(root, path) + if path.is_file() and path.name in operation.build_file_names: + paths.append(path) + return tuple(paths) diff --git a/repo_policy_sync/operations/synchronize_devcontainer_version.py b/repo_policy_sync/operations/synchronize_devcontainer_version.py new file mode 100644 index 0000000..0746d23 --- /dev/null +++ b/repo_policy_sync/operations/synchronize_devcontainer_version.py @@ -0,0 +1,207 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Synchronize SCORE devcontainer versions across Docker and Bazel files.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..bazel import starlark_call_ranges +from ..errors import RepoPolicySyncError +from ..models import Change, EnsureOperation, SynchronizeDevcontainerVersion +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + validate_repository_path, +) + +_NUMERIC_VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") +_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") +_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") + + +@dataclass(frozen=True) +class _VersionLocation: + path: Path + text: str + start: int + end: int + version: tuple[int, int, int] + + +class SynchronizeDevcontainerVersionOperation: + operation_type = "synchronize_devcontainer_version" + operation_class = SynchronizeDevcontainerVersion + + def parse( + self, raw: dict[str, Any], source: Path + ) -> SynchronizeDevcontainerVersion: + expect_keys( + raw, + {"type", "dockerfile", "module_file", "image", "module_name", "rationale"}, + source, + ) + return SynchronizeDevcontainerVersion( + dockerfile=safe_relative_path( + required_string(raw, "dockerfile", source), source + ), + module_file=safe_relative_path( + required_string(raw, "module_file", source), source + ), + image=required_string(raw, "image", source), + module_name=required_string(raw, "module_name", source), + rationale=optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, SynchronizeDevcontainerVersion) + docker, module = _locations(root, operation) + if docker.version == module.version: + return () + target, source = ( + (module, docker) if docker.version > module.version else (docker, module) + ) + return ( + Change( + target.path, + f"align version from {_version_text(target.version)!r} to {_version_text(source.version)!r}", + operation.rationale, + ), + ) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, SynchronizeDevcontainerVersion) + docker, module = _locations(root, operation) + if docker.version == module.version: + return + target, source = ( + (module, docker) if docker.version > module.version else (docker, module) + ) + replacement = _version_text(source.version) + if target.path == operation.dockerfile: + replacement = f"v{replacement}" + target_file = root / target.path + target_file.write_text( + target.text[: target.start] + replacement + target.text[target.end :], + encoding="utf-8", + ) + + +def _locations( + root: Path, operation: SynchronizeDevcontainerVersion +) -> tuple[_VersionLocation, _VersionLocation]: + docker = _docker_location(root, operation) + module = _module_location(root, operation) + return docker, module + + +def _docker_location( + root: Path, operation: SynchronizeDevcontainerVersion +) -> _VersionLocation: + path = root / operation.dockerfile + validate_repository_path(root, path) + if not path.is_file(): + raise RepoPolicySyncError(f"{operation.dockerfile} must exist") + text = path.read_text(encoding="utf-8") + matches = list( + re.finditer( + rf"(?m)^\s*FROM\s+{re.escape(operation.image)}:(?P[^\s#]+)[^\r\n]*$", + text, + ) + ) + if len(matches) != 1: + raise RepoPolicySyncError( + f"{operation.dockerfile} must contain exactly one FROM {operation.image}:... instruction" + ) + tag = matches[0].group("tag") + if not tag.startswith("v") or (version := _parse_version(tag[1:])) is None: + raise RepoPolicySyncError( + f"{operation.dockerfile} must use {operation.image}:vX.Y.Z, found {tag!r}" + ) + start, end = matches[0].span("tag") + return _VersionLocation(operation.dockerfile, text, start, end, version) + + +def _module_location( + root: Path, operation: SynchronizeDevcontainerVersion +) -> _VersionLocation: + path = root / operation.module_file + validate_repository_path(root, path) + if not path.is_file(): + raise RepoPolicySyncError(f"{operation.module_file} must exist") + text = path.read_text(encoding="utf-8") + calls = [] + for start, end in starlark_call_ranges(text, "bazel_dep"): + # Scan active calls only; a commented dependency must not determine the + # version that is synchronized with the Dockerfile. + body = text[start:end] + name_matches = list(_NAME_ARGUMENT.finditer(body)) + if any( + name_match.group(1) == operation.module_name for name_match in name_matches + ): + if len(name_matches) != 1: + raise RepoPolicySyncError( + f"{operation.module_file} bazel_dep for {operation.module_name!r} " + "must declare name exactly once" + ) + calls.append((start, end)) + if len(calls) != 1: + raise RepoPolicySyncError( + f"{operation.module_file} must contain exactly one bazel_dep for {operation.module_name!r}" + ) + version_matches = list(_VERSION_ARGUMENT.finditer(text[calls[0][0] : calls[0][1]])) + if not version_matches: + raise RepoPolicySyncError( + f'{operation.module_file} bazel_dep for {operation.module_name!r} must declare version = "X.Y.Z"' + ) + if len(version_matches) != 1: + raise RepoPolicySyncError( + f"{operation.module_file} bazel_dep for {operation.module_name!r} must declare version exactly once" + ) + version_match = version_matches[0] + version_text = version_match.group(2) + version = _parse_version(version_text) + if version is None: + raise RepoPolicySyncError( + f"{operation.module_file} bazel_dep for {operation.module_name!r} must use X.Y.Z, found {version_text!r}" + ) + start = calls[0][0] + version_match.start(2) + end = calls[0][0] + version_match.end(2) + return _VersionLocation(operation.module_file, text, start, end, version) + + +def _parse_version(value: str) -> tuple[int, int, int] | None: + match = _NUMERIC_VERSION.fullmatch(value) + return tuple(int(component) for component in match.groups()) if match else None + + +def _version_text(version: tuple[int, int, int]) -> str: + return ".".join(str(component) for component in version) diff --git a/repo_policy_sync/operations/synchronize_file.py b/repo_policy_sync/operations/synchronize_file.py new file mode 100644 index 0000000..bab510c --- /dev/null +++ b/repo_policy_sync/operations/synchronize_file.py @@ -0,0 +1,450 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""The synchronize_file operation.""" + +from __future__ import annotations + +import re +import stat +from pathlib import Path +from typing import Any + +from ..errors import PolicyError, RepoPolicySyncError +from ..models import Change, EnsureOperation, SynchronizeFile +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + validate_repository_path, +) + + +class SynchronizeFileOperation: + """Synchronize a repository file with a UTF-8 asset beside its policy.""" + + operation_type = "synchronize_file" + operation_class = SynchronizeFile + + def parse(self, raw: dict[str, Any], source: Path) -> SynchronizeFile: + expect_keys( + raw, + { + "type", + "path", + "source", + "executable", + "preserve_reusable_workflow_refs", + "preserve_workflow_content", + "rationale", + }, + source, + ) + asset = safe_relative_path(required_string(raw, "source", source), source) + asset_path = source.parent / asset + if not asset_path.is_file(): + raise PolicyError( + f"policy {source}: synchronize_file source must be an existing file: {asset}" + ) + executable = raw.get("executable", False) + if not isinstance(executable, bool): + raise PolicyError(f"policy {source}: executable must be a boolean") + preserve_refs = _parse_workflow_ref_rules( + raw.get("preserve_reusable_workflow_refs", []), source + ) + preserve_workflow_content = raw.get("preserve_workflow_content", False) + if not isinstance(preserve_workflow_content, bool): + raise PolicyError( + f"policy {source}: preserve_workflow_content must be a boolean" + ) + try: + contents = asset_path.read_text(encoding="utf-8") + except OSError as exc: + raise PolicyError( + f"policy {source}: could not read synchronize_file source {asset}: {exc}" + ) from exc + except UnicodeError as exc: + raise PolicyError( + f"policy {source}: synchronize_file source must be UTF-8: {asset}" + ) from exc + return SynchronizeFile( + path=safe_relative_path(required_string(raw, "path", source), source), + contents=contents, + executable=executable, + preserve_reusable_workflow_refs=preserve_refs, + preserve_workflow_content=preserve_workflow_content, + rationale=optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, SynchronizeFile) + path = root / operation.path + validate_repository_path(root, path) + _validate_target(path, operation) + content_changed = not path.is_file() or ( + _desired_contents(path, operation) != path.read_text(encoding="utf-8") + ) + executable_changed = ( + operation.executable and path.is_file() and not _is_executable(path) + ) + if content_changed and executable_changed: + description = "synchronize contents and make executable" + elif content_changed: + description = "add file" if not path.exists() else "synchronize contents" + elif executable_changed: + description = "make executable" + else: + return () + return (Change(operation.path, description, operation.rationale),) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, SynchronizeFile) + path = root / operation.path + validate_repository_path(root, path) + _validate_target(path, operation) + desired_contents = _desired_contents(path, operation) + if not path.is_file() or path.read_text(encoding="utf-8") != desired_contents: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(desired_contents, encoding="utf-8") + if operation.executable and not _is_executable(path): + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def _validate_target(path: Path, operation: SynchronizeFile) -> None: + if path.exists() and not path.is_file(): + raise RepoPolicySyncError(f"{operation.path} must not be a directory") + + +def _is_executable(path: Path) -> bool: + return bool(path.stat().st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)) + + +def _parse_workflow_ref_rules( + raw: object, source: Path +) -> tuple[tuple[str, tuple[int, int, int]], ...]: + if raw == []: + return () + if not isinstance(raw, list) or not raw: + raise PolicyError( + f"policy {source}: preserve_reusable_workflow_refs must be a non-empty list" + ) + rules: list[tuple[str, tuple[int, int, int]]] = [] + for index, item in enumerate(raw): + if not isinstance(item, dict) or set(item) != {"workflow", "minimum_version"}: + raise PolicyError( + f"policy {source}: preserve_reusable_workflow_refs[{index}] " + "must contain only workflow and minimum_version" + ) + workflow = required_string(item, "workflow", source) + version = required_string(item, "minimum_version", source) + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", version) + if match is None: + raise PolicyError( + f"policy {source}: preserve_reusable_workflow_refs[{index}].minimum_version " + "must use major.minor.patch syntax" + ) + rules.append((workflow, tuple(int(part) for part in match.groups()))) + return tuple(rules) + + +def _desired_contents(path: Path, operation: SynchronizeFile) -> str: + desired = operation.contents + if not path.is_file(): + return desired + + existing = path.read_text(encoding="utf-8") + if operation.preserve_workflow_content: + desired = _merge_workflow_content( + existing, desired, operation.preserve_reusable_workflow_refs + ) + if not operation.preserve_reusable_workflow_refs: + return desired + + for workflow, minimum_version in operation.preserve_reusable_workflow_refs: + pattern = re.compile(rf"(?P{re.escape(workflow)}@)(?P[^\s#]+)") + source_matches = list(pattern.finditer(operation.contents)) + desired_matches = list(pattern.finditer(desired)) + existing_matches = list(pattern.finditer(existing)) + for index, source_match in reversed(list(enumerate(source_matches))): + if index >= len(desired_matches): + continue + source_ref = source_match.group("ref") + if index >= len(existing_matches): + continue + desired_match = desired_matches[index] + existing_ref = existing_matches[index].group("ref") + selected_ref = _preserved_ref(existing_ref, source_ref, minimum_version) + if selected_ref != desired_match.group("ref"): + desired = ( + desired[: desired_match.start("ref")] + + selected_ref + + desired[desired_match.end("ref") :] + ) + return desired + + +def _merge_workflow_content( + existing: str, + source: str, + rules: tuple[tuple[str, tuple[int, int, int]], ...], +) -> str: + """Apply the standard workflow envelope without replacing local jobs.""" + + merged = existing + for section in ("name", "on"): + merged = _replace_top_level_section(merged, source, section) + # Use the policy asset as the source of truth for top-level permissions. + # This both removes repository-specific write permissions when the asset + # is unprivileged and preserves an explicitly required read permission. + if _top_level_section(source, "permissions") is None: + merged = _remove_top_level_section(merged, "permissions") + else: + merged = _replace_top_level_section(merged, source, "permissions") + + merged = _merge_workflow_jobs(merged, source, rules) + # Workflow files are line-oriented YAML; always leave a separator for a + # following section and a final newline for tools that rewrite the file. + return merged if merged.endswith("\n") else merged + "\n" + + +def _merge_workflow_jobs( + existing: str, + source: str, + rules: tuple[tuple[str, tuple[int, int, int]], ...], +) -> str: + if not rules: + return existing + source_jobs = _top_level_section(source, "jobs") + source_job = ( + _matching_job_block(source[source_jobs[0] : source_jobs[1]], rules) + if source_jobs is not None + else None + ) + if source_job is None: + return existing + existing_jobs = _top_level_section(existing, "jobs") + existing_job = ( + _matching_job_location(existing[existing_jobs[0] : existing_jobs[1]], rules) + if existing_jobs is not None + else None + ) + if existing_jobs is not None and existing_job is not None: + return _merge_matching_job_permissions(existing, source, rules) + return _append_workflow_job(existing, source, rules) + + +def _replace_top_level_section(existing: str, source: str, key: str) -> str: + source_section = _top_level_section(source, key) + existing_section = _top_level_section(existing, key) + if source_section is None: + return existing + if existing_section is None: + first_section = re.search(r"(?m)^([^\s#][^:\n]*):[^\n]*(?:\n|$)", existing) + insert_at = ( + first_section.start() if first_section is not None else len(existing) + ) + prefix = existing[:insert_at] + if prefix and not prefix.endswith("\n"): + prefix += "\n" + return ( + prefix + + source[source_section[0] : source_section[1]] + + existing[insert_at:] + ) + start, end = existing_section + replacement = source[source_section[0] : source_section[1]] + if existing[end:] and not replacement.endswith("\n"): + # A source section without a trailing newline would otherwise join the + # next existing top-level key into the same YAML line. + replacement += "\n" + return existing[:start] + replacement + existing[end:] + + +def _remove_top_level_section(text: str, key: str) -> str: + section = _top_level_section(text, key) + if section is None: + return text + return text[: section[0]] + text[section[1] :] + + +def _top_level_section(text: str, key: str) -> tuple[int, int] | None: + lines = list(re.finditer(r"(?m)^([^\s#][^:\n]*):[^\n]*(?:\n|$)", text)) + for index, match in enumerate(lines): + if match.group(1).strip().strip("\"'") != key: + continue + end = lines[index + 1].start() if index + 1 < len(lines) else len(text) + return match.start(), end + return None + + +def _append_workflow_job( + existing: str, + source: str, + rules: tuple[tuple[str, tuple[int, int, int]], ...], +) -> str: + source_jobs = _top_level_section(source, "jobs") + if source_jobs is None: + return existing + source_job = _matching_job_block(source[source_jobs[0] : source_jobs[1]], rules) + if source_job is None: + return existing + + existing_jobs = _top_level_section(existing, "jobs") + if existing_jobs is None: + separator = "" if existing.endswith("\n") else "\n" + return existing + separator + source[source_jobs[0] : source_jobs[1]] + + source_job_name = _job_name(source_job) + existing_job_names = { + match.group("name") + for match in _mapping_entries(existing[existing_jobs[0] : existing_jobs[1]]) + } + if source_job_name is not None and source_job_name in existing_job_names: + raise RepoPolicySyncError( + f"cannot append workflow job {source_job_name!r}: " + "a job with that ID already exists" + ) + + _, end = existing_jobs + prefix = existing[:end] + separator = "" if prefix.endswith("\n") else "\n" + return prefix + separator + source_job + existing[end:] + + +def _merge_matching_job_permissions( + existing: str, + source: str, + rules: tuple[tuple[str, tuple[int, int, int]], ...], +) -> str: + existing_jobs = _top_level_section(existing, "jobs") + source_jobs = _top_level_section(source, "jobs") + assert existing_jobs is not None + assert source_jobs is not None + existing_jobs_text = existing[existing_jobs[0] : existing_jobs[1]] + source_jobs_text = source[source_jobs[0] : source_jobs[1]] + existing_location = _matching_job_location(existing_jobs_text, rules) + source_location = _matching_job_location(source_jobs_text, rules) + assert existing_location is not None + assert source_location is not None + existing_job_start, existing_job_end = existing_location + source_job_start, source_job_end = source_location + existing_job = existing_jobs_text[existing_job_start:existing_job_end] + source_job = source_jobs_text[source_job_start:source_job_end] + source_permissions_location = _nested_job_section(source_job, "permissions") + if source_permissions_location is None: + return existing + source_permissions = source_job[ + source_permissions_location[0] : source_permissions_location[1] + ] + existing_permissions = _nested_job_section(existing_job, "permissions") + if existing_permissions is None: + insertion = existing_jobs[0] + existing_job_end + prefix = "" if existing[:insertion].endswith("\n") else "\n" + return existing[:insertion] + prefix + source_permissions + existing[insertion:] + permission_start, permission_end = existing_permissions + absolute_start = existing_jobs[0] + existing_job_start + permission_start + absolute_end = existing_jobs[0] + existing_job_start + permission_end + return existing[:absolute_start] + source_permissions + existing[absolute_end:] + + +def _job_name(job_block: str) -> str | None: + match = re.match(r"[ \t]+([^\s#][^:\n]*):", job_block) + return match.group(1) if match is not None else None + + +def _matching_job_block( + jobs_section: str, + rules: tuple[tuple[str, tuple[int, int, int]], ...], +) -> str | None: + location = _matching_job_location(jobs_section, rules) + return jobs_section[location[0] : location[1]] if location is not None else None + + +def _matching_job_location( + jobs_section: str, + rules: tuple[tuple[str, tuple[int, int, int]], ...], +) -> tuple[int, int] | None: + job_lines = _mapping_entries(jobs_section) + for index, match in enumerate(job_lines): + end = ( + job_lines[index + 1].start() + if index + 1 < len(job_lines) + else len(jobs_section) + ) + block = jobs_section[match.start() : end] + if any(re.search(re.escape(workflow) + r"@", block) for workflow, _ in rules): + return match.start(), end + return None + + +def _nested_job_section(text: str, key: str) -> tuple[int, int] | None: + lines = _mapping_entries(text, nested=True) + for index, match in enumerate(lines): + if match.group("name").strip().strip("\"'") != key: + continue + end = lines[index + 1].start() if index + 1 < len(lines) else len(text) + return match.start(), end + return None + + +def _mapping_entries(text: str, *, nested: bool = False) -> list[re.Match[str]]: + """Return mapping entries at the relevant indentation level. + + Workflow files commonly use two or four spaces. Selecting the first + mapping indentation present keeps job detection independent of that local + style without attempting to parse all of YAML. + """ + + candidates = list( + re.finditer( + r"(?m)^(?P[ \t]+)(?P[^\s#][^:\n]*):[^\n]*(?:\n|$)", + text, + ) + ) + if not candidates: + return [] + widths = [len(match.group("indent").expandtabs(8)) for match in candidates] + if nested: + parent_width = widths[0] + child_widths = [width for width in widths if width > parent_width] + if not child_widths: + return [] + target_width = min(child_widths) + else: + target_width = min(widths) + return [match for match, width in zip(candidates, widths) if width == target_width] + + +def _preserved_ref( + existing_ref: str, source_ref: str, minimum_version: tuple[int, int, int] +) -> str: + match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", existing_ref) + if match is None: + # A branch or an unknown immutable ref may point at a newer release. Keep + # it because the policy cannot prove that replacing it is safe. + return existing_ref + existing_version = tuple(int(part) for part in match.groups()) + return existing_ref if existing_version >= minimum_version else source_ref diff --git a/repo_policy_sync/operations/synchronize_workflow.py b/repo_policy_sync/operations/synchronize_workflow.py new file mode 100644 index 0000000..57c9db1 --- /dev/null +++ b/repo_policy_sync/operations/synchronize_workflow.py @@ -0,0 +1,414 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Synchronize a reusable workflow without replacing its repository envelope.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import yaml + +from ..errors import PolicyError, RepoPolicySyncError +from ..models import Change, EnsureOperation, SynchronizeWorkflow +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + string_list, + validate_repository_path, +) +from .synchronize_file import ( + _merge_workflow_content, + _merge_workflow_jobs, + _mapping_entries, + _preserved_ref, + _replace_top_level_section, + _top_level_section, +) + + +class SynchronizeWorkflowOperation: + """Synchronize a selected reusable workflow and its optional workflow_run.""" + + operation_type = "synchronize_workflow" + operation_class = SynchronizeWorkflow + + def parse(self, raw: dict[str, Any], source: Path) -> SynchronizeWorkflow: + expect_keys( + raw, + { + "type", + "source", + "reusable_workflow", + "minimum_version", + "required_triggers", + "workflow_run", + "rationale", + }, + source, + ) + asset = safe_relative_path(required_string(raw, "source", source), source) + contents = _read_asset(source.parent / asset, source, asset) + reusable_workflow = required_string(raw, "reusable_workflow", source) + minimum_version = _parse_version( + required_string(raw, "minimum_version", source), source + ) + required_triggers = string_list( + raw.get("required_triggers", []), "required_triggers", source + ) + if not required_triggers: + raise PolicyError(f"policy {source}: required_triggers must not be empty") + _validate_source_triggers(contents, required_triggers, source) + + workflow_run_path: Path | None = None + workflow_run_contents: str | None = None + workflow_run = raw.get("workflow_run") + if workflow_run is not None: + if not isinstance(workflow_run, dict) or set(workflow_run) != { + "path", + "source", + }: + raise PolicyError( + f"policy {source}: workflow_run must contain only path and source" + ) + workflow_run_path = safe_relative_path( + required_string(workflow_run, "path", source), source + ) + workflow_run_asset = safe_relative_path( + required_string(workflow_run, "source", source), source + ) + workflow_run_contents = _read_asset( + source.parent / workflow_run_asset, source, workflow_run_asset + ) + + return SynchronizeWorkflow( + source=asset, + contents=contents, + reusable_workflow=reusable_workflow, + minimum_version=minimum_version, + required_triggers=required_triggers, + workflow_run_path=workflow_run_path, + workflow_run_contents=workflow_run_contents, + rationale=optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, SynchronizeWorkflow) + target = _find_workflow(root, operation.reusable_workflow) + target_text = target.read_text(encoding="utf-8") + desired_target = _desired_workflow(target_text, operation) + changes: list[Change] = [] + if desired_target != target_text: + changes.append( + Change( + target.relative_to(root), + "synchronize workflow", + operation.rationale, + ) + ) + + if operation.workflow_run_path is not None: + workflow_run_path = root / operation.workflow_run_path + validate_repository_path(root, workflow_run_path) + workflow_run_text = ( + workflow_run_path.read_text(encoding="utf-8") + if workflow_run_path.is_file() + else None + ) + desired_workflow_run = _desired_workflow_run( + workflow_run_text, + operation.workflow_run_contents, + operation, + _selected_workflow_name(target_text, operation.contents, target), + ) + if workflow_run_text != desired_workflow_run: + changes.append( + Change( + operation.workflow_run_path, + "synchronize workflow", + operation.rationale, + ) + ) + return tuple(changes) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, SynchronizeWorkflow) + target = _find_workflow(root, operation.reusable_workflow) + target_text = target.read_text(encoding="utf-8") + desired_target = _desired_workflow(target_text, operation) + if desired_target != target_text: + target.write_text(desired_target, encoding="utf-8") + + if operation.workflow_run_path is None: + return + workflow_run_path = root / operation.workflow_run_path + validate_repository_path(root, workflow_run_path) + workflow_run_text = ( + workflow_run_path.read_text(encoding="utf-8") + if workflow_run_path.is_file() + else None + ) + desired_workflow_run = _desired_workflow_run( + workflow_run_text, + operation.workflow_run_contents, + operation, + _selected_workflow_name(target_text, operation.contents, target), + ) + if workflow_run_text != desired_workflow_run: + workflow_run_path.parent.mkdir(parents=True, exist_ok=True) + workflow_run_path.write_text(desired_workflow_run, encoding="utf-8") + + +def _read_asset(path: Path, source: Path, relative: Path) -> str: + if not path.is_file(): + raise PolicyError( + f"policy {source}: synchronize_workflow source must be an existing file: {relative}" + ) + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise PolicyError( + f"policy {source}: could not read synchronize_workflow source {relative}: {exc}" + ) from exc + except UnicodeError as exc: + raise PolicyError( + f"policy {source}: synchronize_workflow source must be UTF-8: {relative}" + ) from exc + + +def _parse_version(raw: str, source: Path) -> tuple[int, int, int]: + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", raw) + if match is None: + raise PolicyError( + f"policy {source}: minimum_version must use major.minor.patch syntax" + ) + return tuple(int(part) for part in match.groups()) + + +def _validate_source_triggers( + source_text: str, required_triggers: tuple[str, ...], source: Path +) -> None: + on = _top_level_section(source_text, "on") + if on is None: + raise PolicyError(f"policy {source}: workflow source must define on") + event_names = { + match.group("name").strip().strip("\"'") + for match in _mapping_entries(source_text[on[0] : on[1]]) + } + missing = sorted(set(required_triggers) - event_names) + if missing: + raise PolicyError( + f"policy {source}: workflow source is missing required triggers: " + + ", ".join(missing) + ) + + +def _find_workflow(root: Path, reusable_workflow: str) -> Path: + workflows = root / ".github/workflows" + validate_repository_path(root, workflows) + if not workflows.is_dir(): + raise RepoPolicySyncError(f"workflow directory does not exist: {workflows}") + pattern = re.compile(rf"(?m)^[ \t]+uses:\s*{re.escape(reusable_workflow)}@") + matches: list[Path] = [] + for path in sorted(workflows.rglob("*")): + if path.suffix not in {".yml", ".yaml"} or not path.is_file(): + continue + validate_repository_path(root, path) + if pattern.search(path.read_text(encoding="utf-8")): + matches.append(path) + if len(matches) != 1: + rendered = ", ".join(str(path.relative_to(root)) for path in matches) + expectation = "exactly one" if len(matches) == 0 else "only one" + raise RepoPolicySyncError( + f"expected {expectation} workflow calling {reusable_workflow!r}; found {rendered or 'none'}" + ) + return matches[0] + + +def _desired_workflow(existing: str, operation: SynchronizeWorkflow) -> str: + rules = ((operation.reusable_workflow, operation.minimum_version),) + desired = _ensure_triggers( + existing, operation.contents, operation.required_triggers + ) + source_permissions = _top_level_section(operation.contents, "permissions") + if source_permissions is None: + desired = _remove_top_level_section(desired, "permissions") + else: + desired = _replace_top_level_section(desired, operation.contents, "permissions") + desired = _merge_workflow_jobs(desired, operation.contents, rules) + return _preserve_refs( + existing, + desired, + operation.contents, + operation.reusable_workflow, + operation.minimum_version, + ) + + +def _desired_workflow_run( + existing: str | None, + source: str | None, + operation: SynchronizeWorkflow, + workflow_name: str, +) -> str: + assert source is not None + workflow_run_workflow = _reusable_workflow_in(source) + rules = ((workflow_run_workflow, operation.minimum_version),) + desired = ( + source if existing is None else _merge_workflow_content(existing, source, rules) + ) + desired = _set_workflow_run_name(desired, workflow_name) + if existing is not None: + desired = _preserve_refs(existing, desired, source, rules[0][0], rules[0][1]) + return desired if desired.endswith("\n") else desired + "\n" + + +def _workflow_name(text: str, path: Path) -> str: + match = re.search(r"(?m)^name:\s*(?P[^\n]+)$", text) + if match is None: + raise RepoPolicySyncError(f"workflow {path} must define a top-level name") + try: + value = yaml.safe_load(match.group("value").strip()) + except yaml.YAMLError as exc: + raise RepoPolicySyncError(f"workflow {path} has an invalid name") from exc + if not isinstance(value, str) or not value: + raise RepoPolicySyncError(f"workflow {path} must define a string name") + return value + + +def _selected_workflow_name(existing: str, source: str, path: Path) -> str: + try: + return _workflow_name(existing, path) + except RepoPolicySyncError: + return _workflow_name(source, path) + + +def _reusable_workflow_in(text: str) -> str: + matches = re.findall(r"(?m)^[ \t]+uses:\s*(?P[^@\s]+)@[^\s#]+", text) + if len(matches) != 1: + raise RepoPolicySyncError( + "workflow source must contain exactly one reusable workflow call" + ) + return matches[0] + + +def _ensure_triggers(existing: str, source: str, required: tuple[str, ...]) -> str: + source_on = _top_level_section(source, "on") + existing_on = _top_level_section(existing, "on") + if source_on is None: + raise RepoPolicySyncError("workflow source must define on") + if existing_on is None: + return _replace_top_level_section(existing, source, "on") + source_on_text = source[source_on[0] : source_on[1]] + existing_on_text = existing[existing_on[0] : existing_on[1]] + source_entries = _mapping_entries(source_on_text) + existing_entries = _mapping_entries(existing_on_text) + if not source_entries or not existing_entries: + raise RepoPolicySyncError( + "workflow trigger synchronization requires mapping-style on sections" + ) + source_locations = { + match.group("name").strip().strip("\"'"): (index, match) + for index, match in enumerate(source_entries) + } + existing_names = { + match.group("name").strip().strip("\"'") for match in existing_entries + } + missing = [name for name in required if name not in existing_names] + if not missing: + return existing + additions: list[str] = [] + for name in missing: + index, match = source_locations[name] + end = ( + source_entries[index + 1].start() + if index + 1 < len(source_entries) + else len(source_on_text) + ) + additions.append(source_on_text[match.start() : end].strip("\n")) + replacement = existing_on_text.rstrip("\n") + "\n" + "\n".join(additions) + "\n" + return existing[: existing_on[0]] + replacement + existing[existing_on[1] :] + + +def _set_workflow_run_name(text: str, workflow_name: str) -> str: + on = _top_level_section(text, "on") + if on is None: + raise RepoPolicySyncError("workflow_run workflow must define on") + on_text = text[on[0] : on[1]] + if re.search(r"(?m)^\s+workflow_run:", on_text) is None: + raise RepoPolicySyncError("workflow_run workflow must define on.workflow_run") + replacement, replacements = re.subn( + r"(?m)^(?P[ \t]+)workflows:\s*[^\n]*$", + lambda match: ( + f"{match.group('indent')}workflows: [{json.dumps(workflow_name, ensure_ascii=False)}]" + ), + on_text, + ) + if replacements != 1: + raise RepoPolicySyncError( + "workflow_run workflow must define on.workflow_run.workflows" + ) + return text[: on[0]] + replacement + text[on[1] :] + + +def _preserve_refs( + existing: str, + desired: str, + source: str, + workflow: str, + minimum_version: tuple[int, int, int], +) -> str: + pattern = re.compile(rf"(?P{re.escape(workflow)}@)(?P[^\s#]+)") + source_matches = list(pattern.finditer(source)) + existing_matches = list(pattern.finditer(existing)) + desired_matches = list(pattern.finditer(desired)) + for index, source_match in reversed(list(enumerate(source_matches))): + if index >= len(existing_matches) or index >= len(desired_matches): + continue + desired_match = desired_matches[index] + selected = _preserved_ref( + existing_matches[index].group("ref"), + source_match.group("ref"), + minimum_version, + ) + if selected != desired_match.group("ref"): + desired = ( + desired[: desired_match.start("ref")] + + selected + + desired[desired_match.end("ref") :] + ) + return desired + + +def _remove_top_level_section(text: str, key: str) -> str: + section = _top_level_section(text, key) + if section is None: + return text + return text[: section[0]] + text[section[1] :] diff --git a/repo_policy_sync/policies/README.md b/repo_policy_sync/policies/README.md new file mode 100644 index 0000000..359f16f --- /dev/null +++ b/repo_policy_sync/policies/README.md @@ -0,0 +1,65 @@ + + +# Bundled policy overview + +This directory contains the bundled repository policies. Each policy lives in +its own directory and is defined by a `policy.yml` file. The directory name is +the policy ID used by the CLI, policy-owned branches, and pull-request markers. + +The policies are evaluated independently. A policy that does not match its +`when` conditions is not applicable and makes no change; a matching policy +reports or applies only the changes described by its own `ensure` operations. + +For tutorials, how-to guides, interface reference, and design explanations, +use the [documentation index](../docs/README.md). + +## Policies + +| Policy | Responsibility | Typical lifecycle | +| --- | --- | --- | +| `docs-as-code-legacy-configuration-removal` | Remove obsolete `score_docs_as_code` documentation configuration and legacy ignore entries. | One-time cleanup | +| `minimal-bazel-module-declaration` | Keep `MODULE.bazel` limited to the repository-owned module name by removing version metadata. | One-time cleanup | +| `minimum-bazel-version` | Upgrade repositories to at least Bazel `8.6.0` and regenerate the lockfile when required. | Baseline maintenance | +| `score-bazel-dependency-alignment` | Align SCORE platform, documentation, base-library, and process dependencies, including the `score_process` rename. | Coordinated upgrade | +| `score-devcontainer-dockerfile-migration` | Convert an image-based SCORE devcontainer to a Dockerfile-based configuration. | One-time migration | +| `score-devcontainer-standardization` | Add the direct SCORE devcontainer dependency, standard `run-tool` launcher, and supported launcher paths. | One-time integration | +| `score-devcontainer-version-alignment` | Keep the devcontainer image version and direct Bazel dependency version synchronized. | Recurring maintenance | +| `score-docs-workflow-alignment` | Align shared SCORE documentation build and publish workflows while preserving safe repository-specific content. | Workflow maintenance | + +The policy definitions and their executable before/after cases are the +authoritative detail. The [policy format reference](../docs/reference/policy-format.md) +covers the schema and operation semantics; the [run-a-policy how-to](../docs/how-to/run-a-policy.md) +covers execution. + +## Devcontainer rollout order + +The bundled SCORE devcontainer policies deliberately cover distinct lifecycle +stages: + +1. `score-devcontainer-dockerfile-migration` converts an image-based + `devcontainer.json` so Dependabot can update the development image. +2. `score-devcontainer-standardization` adds the direct Bazel dependency and + standard SCORE tool launcher after a Dockerfile exists. +3. `score-devcontainer-version-alignment` handles later image or module version + changes as recurring maintenance. + +The migration and standardization policies are one-time operations. The +version policy is independent because it is the policy expected to create a +pull request again when a maintainer updates only one of the two version +declarations. The standardization policy may become applicable again if its +managed `run-tool` source asset changes. + +Keep these concerns in separate policies. A single policy run evaluates its +changes before applying them, so newly created files are not visible to later +operations in that same evaluation. diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/after/.gitignore b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/after/.gitignore new file mode 100644 index 0000000..050fca5 --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/after/.gitignore @@ -0,0 +1,3 @@ +/keep +_build +ubproject.toml diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/after/MODULE.bazel b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/after/MODULE.bazel new file mode 100644 index 0000000..a27632a --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/after/MODULE.bazel @@ -0,0 +1,15 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_docs_as_code", version = "1.0") diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/.gitignore b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/.gitignore new file mode 100644 index 0000000..2e0d417 --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/.gitignore @@ -0,0 +1,4 @@ +/keep +/_build +/docs/ubproject.toml +/_build diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/MODULE.bazel b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/MODULE.bazel new file mode 100644 index 0000000..a27632a --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/MODULE.bazel @@ -0,0 +1,15 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_docs_as_code", version = "1.0") diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/ubproject.toml b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/ubproject.toml new file mode 100644 index 0000000..e4476ff --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/ubproject.toml @@ -0,0 +1 @@ +legacy root configuration diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/.gitignore b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/.gitignore new file mode 100644 index 0000000..a485625 --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/.gitignore @@ -0,0 +1 @@ +/_build diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/MODULE.bazel b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/MODULE.bazel new file mode 100644 index 0000000..2f6b4c5 --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/MODULE.bazel @@ -0,0 +1,15 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "other", version = "1.0") diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/ubproject.toml b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/ubproject.toml new file mode 100644 index 0000000..da971f3 --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/ubproject.toml @@ -0,0 +1 @@ +this must remain untouched diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/.gitignore b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/.gitignore new file mode 100644 index 0000000..a485625 --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/.gitignore @@ -0,0 +1 @@ +/_build diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/MODULE.bazel b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/MODULE.bazel new file mode 100644 index 0000000..2f6b4c5 --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/MODULE.bazel @@ -0,0 +1,15 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "other", version = "1.0") diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/ubproject.toml b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/ubproject.toml new file mode 100644 index 0000000..da971f3 --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/ubproject.toml @@ -0,0 +1 @@ +this must remain untouched diff --git a/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/policy.yml b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/policy.yml new file mode 100644 index 0000000..df0323d --- /dev/null +++ b/repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/policy.yml @@ -0,0 +1,35 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +title: "chore(docs): remove legacy score_docs_as_code configuration" +description: > + Replace legacy documentation ignore entries and remove the obsolete docs/ubproject.toml configuration file. + +when: + bazel: + direct_module_dependencies: + - score_docs_as_code +ensure: + - type: ensure_line + path: .gitignore + line: _build + replace_line_globs: + - "*_build*" + - type: ensure_line + path: .gitignore + line: ubproject.toml + replace_line_globs: + - "*ubproject.toml*" + - type: ensure_no_such_file + path: docs/ubproject.toml + - type: ensure_no_such_file + path: ubproject.toml diff --git a/repo_policy_sync/policies/minimal-bazel-module-declaration/compatibility-level-only/after/MODULE.bazel b/repo_policy_sync/policies/minimal-bazel-module-declaration/compatibility-level-only/after/MODULE.bazel new file mode 100644 index 0000000..91e233d --- /dev/null +++ b/repo_policy_sync/policies/minimal-bazel-module-declaration/compatibility-level-only/after/MODULE.bazel @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "score_sbom") diff --git a/repo_policy_sync/policies/minimal-bazel-module-declaration/compatibility-level-only/before/MODULE.bazel b/repo_policy_sync/policies/minimal-bazel-module-declaration/compatibility-level-only/before/MODULE.bazel new file mode 100644 index 0000000..b79351a --- /dev/null +++ b/repo_policy_sync/policies/minimal-bazel-module-declaration/compatibility-level-only/before/MODULE.bazel @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module( + name = "score_sbom", + compatibility_level = 1, +) diff --git a/repo_policy_sync/policies/minimal-bazel-module-declaration/legacy-module/after/MODULE.bazel b/repo_policy_sync/policies/minimal-bazel-module-declaration/legacy-module/after/MODULE.bazel new file mode 100644 index 0000000..9481a92 --- /dev/null +++ b/repo_policy_sync/policies/minimal-bazel-module-declaration/legacy-module/after/MODULE.bazel @@ -0,0 +1,16 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "score_sbom") + +bazel_dep(name = "score_tooling", version = "1.2.0") diff --git a/repo_policy_sync/policies/minimal-bazel-module-declaration/legacy-module/before/MODULE.bazel b/repo_policy_sync/policies/minimal-bazel-module-declaration/legacy-module/before/MODULE.bazel new file mode 100644 index 0000000..18f9c72 --- /dev/null +++ b/repo_policy_sync/policies/minimal-bazel-module-declaration/legacy-module/before/MODULE.bazel @@ -0,0 +1,20 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module( + name = "score_sbom", + version = "0.0.1", + compatibility_level = 1, +) + +bazel_dep(name = "score_tooling", version = "1.2.0") diff --git a/repo_policy_sync/policies/minimal-bazel-module-declaration/policy.yml b/repo_policy_sync/policies/minimal-bazel-module-declaration/policy.yml new file mode 100644 index 0000000..5549a85 --- /dev/null +++ b/repo_policy_sync/policies/minimal-bazel-module-declaration/policy.yml @@ -0,0 +1,20 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +title: "chore(bazel): use a minimal module declaration" +description: Keep the Bazel module declaration limited to its repository-owned name. +ensure: + - type: replace_regex + path: MODULE.bazel + pattern: '(?m)^[ \t]*module\((?=[^)]*\bname\s*=\s*"([^"]+)")(?=[^)]*(?:\bversion\s*=|\bcompatibility_level\s*=))[^)]*\)' + replacement: 'module(name = "\1")' + rationale: Remove module metadata that should be derived by the Bazel Registry. diff --git a/repo_policy_sync/policies/minimal-bazel-module-declaration/version-only/after/MODULE.bazel b/repo_policy_sync/policies/minimal-bazel-module-declaration/version-only/after/MODULE.bazel new file mode 100644 index 0000000..91e233d --- /dev/null +++ b/repo_policy_sync/policies/minimal-bazel-module-declaration/version-only/after/MODULE.bazel @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "score_sbom") diff --git a/repo_policy_sync/policies/minimal-bazel-module-declaration/version-only/before/MODULE.bazel b/repo_policy_sync/policies/minimal-bazel-module-declaration/version-only/before/MODULE.bazel new file mode 100644 index 0000000..2c47649 --- /dev/null +++ b/repo_policy_sync/policies/minimal-bazel-module-declaration/version-only/before/MODULE.bazel @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module( + name = "score_sbom", + version = "0.0.1", +) diff --git a/repo_policy_sync/policies/minimum-bazel-version/minimum/after/.bazelversion b/repo_policy_sync/policies/minimum-bazel-version/minimum/after/.bazelversion new file mode 100644 index 0000000..acd405b --- /dev/null +++ b/repo_policy_sync/policies/minimum-bazel-version/minimum/after/.bazelversion @@ -0,0 +1 @@ +8.6.0 diff --git a/repo_policy_sync/policies/minimum-bazel-version/minimum/before/.bazelversion b/repo_policy_sync/policies/minimum-bazel-version/minimum/before/.bazelversion new file mode 100644 index 0000000..acd405b --- /dev/null +++ b/repo_policy_sync/policies/minimum-bazel-version/minimum/before/.bazelversion @@ -0,0 +1 @@ +8.6.0 diff --git a/repo_policy_sync/policies/minimum-bazel-version/newer-minor/after/.bazelversion b/repo_policy_sync/policies/minimum-bazel-version/newer-minor/after/.bazelversion new file mode 100644 index 0000000..df5119e --- /dev/null +++ b/repo_policy_sync/policies/minimum-bazel-version/newer-minor/after/.bazelversion @@ -0,0 +1 @@ +8.7.0 diff --git a/repo_policy_sync/policies/minimum-bazel-version/newer-minor/before/.bazelversion b/repo_policy_sync/policies/minimum-bazel-version/newer-minor/before/.bazelversion new file mode 100644 index 0000000..df5119e --- /dev/null +++ b/repo_policy_sync/policies/minimum-bazel-version/newer-minor/before/.bazelversion @@ -0,0 +1 @@ +8.7.0 diff --git a/repo_policy_sync/policies/minimum-bazel-version/newer-patch/after/.bazelversion b/repo_policy_sync/policies/minimum-bazel-version/newer-patch/after/.bazelversion new file mode 100644 index 0000000..f6f89a8 --- /dev/null +++ b/repo_policy_sync/policies/minimum-bazel-version/newer-patch/after/.bazelversion @@ -0,0 +1 @@ +8.6.1 diff --git a/repo_policy_sync/policies/minimum-bazel-version/newer-patch/before/.bazelversion b/repo_policy_sync/policies/minimum-bazel-version/newer-patch/before/.bazelversion new file mode 100644 index 0000000..f6f89a8 --- /dev/null +++ b/repo_policy_sync/policies/minimum-bazel-version/newer-patch/before/.bazelversion @@ -0,0 +1 @@ +8.6.1 diff --git a/repo_policy_sync/policies/minimum-bazel-version/older-major/after/.bazelversion b/repo_policy_sync/policies/minimum-bazel-version/older-major/after/.bazelversion new file mode 100644 index 0000000..acd405b --- /dev/null +++ b/repo_policy_sync/policies/minimum-bazel-version/older-major/after/.bazelversion @@ -0,0 +1 @@ +8.6.0 diff --git a/repo_policy_sync/policies/minimum-bazel-version/older-major/before/.bazelversion b/repo_policy_sync/policies/minimum-bazel-version/older-major/before/.bazelversion new file mode 100644 index 0000000..e8be684 --- /dev/null +++ b/repo_policy_sync/policies/minimum-bazel-version/older-major/before/.bazelversion @@ -0,0 +1 @@ +7.6.1 diff --git a/repo_policy_sync/policies/minimum-bazel-version/older-minor/after/.bazelversion b/repo_policy_sync/policies/minimum-bazel-version/older-minor/after/.bazelversion new file mode 100644 index 0000000..acd405b --- /dev/null +++ b/repo_policy_sync/policies/minimum-bazel-version/older-minor/after/.bazelversion @@ -0,0 +1 @@ +8.6.0 diff --git a/repo_policy_sync/policies/minimum-bazel-version/older-minor/before/.bazelversion b/repo_policy_sync/policies/minimum-bazel-version/older-minor/before/.bazelversion new file mode 100644 index 0000000..85e2cd5 --- /dev/null +++ b/repo_policy_sync/policies/minimum-bazel-version/older-minor/before/.bazelversion @@ -0,0 +1 @@ +8.5.2 diff --git a/repo_policy_sync/policies/minimum-bazel-version/policy.yml b/repo_policy_sync/policies/minimum-bazel-version/policy.yml new file mode 100644 index 0000000..8830a42 --- /dev/null +++ b/repo_policy_sync/policies/minimum-bazel-version/policy.yml @@ -0,0 +1,24 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +title: "chore(bazel): require Bazel 8.6.0" +description: | + Upgrade repositories using a Bazel release older than 8.6.0. Versions older + than 8.6.0 are no longer supported, due to a breaking change. +ensure: + - type: ensure_minimum_version + path: .bazelversion + minimum_version: 8.6.0 +after_apply: + - command: [bazel, mod, deps] + when_file_exists: MODULE.bazel.lock + description: Regenerate MODULE.bazel.lock with `bazel mod deps`. diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/after/BUILD b/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/after/BUILD new file mode 100644 index 0000000..7ea1323 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/after/BUILD @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +deps = ["@score_process_description//:api"] diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/after/MODULE.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/after/MODULE.bazel new file mode 100644 index 0000000..5345f30 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/after/MODULE.bazel @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_platform", version = "0.7.0") +bazel_dep(name = "score_docs_as_code", version = "8.0.0") +bazel_dep(name = "score_process_description", version = "2.1.1") diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/before/BUILD b/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/before/BUILD new file mode 100644 index 0000000..7ea1323 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/before/BUILD @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +deps = ["@score_process_description//:api"] diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/before/MODULE.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/before/MODULE.bazel new file mode 100644 index 0000000..5345f30 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/before/MODULE.bazel @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_platform", version = "0.7.0") +bazel_dep(name = "score_docs_as_code", version = "8.0.0") +bazel_dep(name = "score_process_description", version = "2.1.1") diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/baselibs-only/after/MODULE.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/baselibs-only/after/MODULE.bazel new file mode 100644 index 0000000..930adbc --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/baselibs-only/after/MODULE.bazel @@ -0,0 +1,21 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_baselibs", version = "0.2.11") + +git_override( + module_name = "score_baselibs", + commit = "bf0020fefef402642dcb0092832e03ba4267d739", + remote = "https://github.com/eclipse-score/baselibs.git", +) diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/baselibs-only/before/MODULE.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/baselibs-only/before/MODULE.bazel new file mode 100644 index 0000000..81fd9bf --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/baselibs-only/before/MODULE.bazel @@ -0,0 +1,15 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_baselibs", version = "0.2.11") diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/after/MODULE.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/after/MODULE.bazel new file mode 100644 index 0000000..5345f30 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/after/MODULE.bazel @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_platform", version = "0.7.0") +bazel_dep(name = "score_docs_as_code", version = "8.0.0") +bazel_dep(name = "score_process_description", version = "2.1.1") diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/after/subproject/BUILD b/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/after/subproject/BUILD new file mode 100644 index 0000000..70d22e0 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/after/subproject/BUILD @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +deps = ["@score_process//:api"] diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/before/MODULE.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/before/MODULE.bazel new file mode 100644 index 0000000..5345f30 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/before/MODULE.bazel @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_platform", version = "0.7.0") +bazel_dep(name = "score_docs_as_code", version = "8.0.0") +bazel_dep(name = "score_process_description", version = "2.1.1") diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/before/subproject/BUILD b/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/before/subproject/BUILD new file mode 100644 index 0000000..70d22e0 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/before/subproject/BUILD @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +deps = ["@score_process//:api"] diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/after/BUILD b/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/after/BUILD new file mode 100644 index 0000000..7ea1323 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/after/BUILD @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +deps = ["@score_process_description//:api"] diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/after/MODULE.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/after/MODULE.bazel new file mode 100644 index 0000000..5345f30 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/after/MODULE.bazel @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_platform", version = "0.7.0") +bazel_dep(name = "score_docs_as_code", version = "8.0.0") +bazel_dep(name = "score_process_description", version = "2.1.1") diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/before/BUILD b/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/before/BUILD new file mode 100644 index 0000000..70d22e0 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/before/BUILD @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +deps = ["@score_process//:api"] diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/before/MODULE.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/before/MODULE.bazel new file mode 100644 index 0000000..c450ef0 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/before/MODULE.bazel @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_platform", version = "0.7.0") +bazel_dep(name = "score_docs_as_code", version = "8.0.0") +bazel_dep(name = "score_process_description", version = "2.0.4") diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/BUILD b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/BUILD new file mode 100644 index 0000000..7ea1323 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/BUILD @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +deps = ["@score_process_description//:api"] diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/MODULE.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/MODULE.bazel new file mode 100644 index 0000000..5345f30 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/MODULE.bazel @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_platform", version = "0.7.0") +bazel_dep(name = "score_docs_as_code", version = "8.0.0") +bazel_dep(name = "score_process_description", version = "2.1.1") diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/components/BUILD.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/components/BUILD.bazel new file mode 100644 index 0000000..66f5099 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/components/BUILD.bazel @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@score_process_description//:defs.bzl", "score_rule") diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/BUILD b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/BUILD new file mode 100644 index 0000000..70d22e0 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/BUILD @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +deps = ["@score_process//:api"] diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/MODULE.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/MODULE.bazel new file mode 100644 index 0000000..dae404c --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/MODULE.bazel @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_platform", version = "0.6.0") +bazel_dep(name = "score_docs_as_code", version = "7.3.0") +bazel_dep(name = "score_process", version = "1.9.0") diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/components/BUILD.bazel b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/components/BUILD.bazel new file mode 100644 index 0000000..f955d5f --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/components/BUILD.bazel @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@score_process//:defs.bzl", "score_rule") diff --git a/repo_policy_sync/policies/score-bazel-dependency-alignment/policy.yml b/repo_policy_sync/policies/score-bazel-dependency-alignment/policy.yml new file mode 100644 index 0000000..edc72f4 --- /dev/null +++ b/repo_policy_sync/policies/score-bazel-dependency-alignment/policy.yml @@ -0,0 +1,52 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +title: "chore(bazel): align SCORE platform dependencies" +description: | + Keep the SCORE platform, documentation, base libraries, and process + dependencies on compatible releases. Migrate the legacy score_process module + and every BUILD reference to score_process_description as part of the + coordinated upgrade. +when: + bazel: + # Any one of these version conditions is enough to require alignment. + any_direct_module_conditions: + - score_platform < 0.7.0 + - score_docs_as_code < 8.0.0 + - score_baselibs < 0.2.12 + - score_process_description < 2.1.1 +ensure: + - type: synchronize_bazel_dependencies + module_file: MODULE.bazel + dependencies: + - name: score_platform + version: 0.7.0 + optional: true + - name: score_docs_as_code + version: 8.0.0 + optional: true + - name: score_baselibs + version: 0.2.11 + override: bf0020fefef402642dcb0092832e03ba4267d739 + remote: https://github.com/eclipse-score/baselibs.git + optional: true + - name: score_process + replacement_name: score_process_description + version: 2.1.1 + optional: true + build_file_names: [BUILD, BUILD.bazel] + rationale: Keep the SCORE dependencies compatible and complete the score_process rename. +after_apply: + - command: [bazel, mod, deps] + when_file_exists: MODULE.bazel.lock + when_path_changed: MODULE.bazel + description: Regenerate MODULE.bazel.lock with `bazel mod deps`. diff --git a/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/copyright-header b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/copyright-header new file mode 100644 index 0000000..ca5de74 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/copyright-header @@ -0,0 +1,12 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* diff --git a/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/after/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/after/.devcontainer/Dockerfile new file mode 100644 index 0000000..6bfb773 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/after/.devcontainer/Dockerfile @@ -0,0 +1,15 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Use Dockerfile to get dependabot version bumps after new image is released +FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 diff --git a/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/after/.devcontainer/devcontainer.json b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/after/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a95ffb6 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/after/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + // JSONC is accepted by devcontainer configuration files. + "name": "SCORE development", + "build": { + "dockerfile": "Dockerfile" + }, +} diff --git a/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/before/.devcontainer.json b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/before/.devcontainer.json new file mode 100644 index 0000000..3ab5cf4 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/before/.devcontainer.json @@ -0,0 +1,5 @@ +{ + // JSONC is accepted by devcontainer configuration files. + "name": "SCORE development", + "image": "ghcr.io/eclipse-score/devcontainer:v1.9.0", +} diff --git a/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/after/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/after/.devcontainer/Dockerfile new file mode 100644 index 0000000..6bfb773 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/after/.devcontainer/Dockerfile @@ -0,0 +1,15 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Use Dockerfile to get dependabot version bumps after new image is released +FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 diff --git a/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/after/.devcontainer/devcontainer.json b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/after/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a95ffb6 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/after/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + // JSONC is accepted by devcontainer configuration files. + "name": "SCORE development", + "build": { + "dockerfile": "Dockerfile" + }, +} diff --git a/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/before/.devcontainer/devcontainer.json b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/before/.devcontainer/devcontainer.json new file mode 100644 index 0000000..3ab5cf4 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/before/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + // JSONC is accepted by devcontainer configuration files. + "name": "SCORE development", + "image": "ghcr.io/eclipse-score/devcontainer:v1.9.0", +} diff --git a/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/policy.yml b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/policy.yml new file mode 100644 index 0000000..a6bb5b2 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/policy.yml @@ -0,0 +1,34 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +title: "chore(devcontainer): use a Dockerfile for the SCORE devcontainer" +description: | + Replace the image-based SCORE devcontainer configuration with a Dockerfile + so Dependabot can keep the development image up to date. +when: + file_contains_any: + - path: .devcontainer.json + pattern: '"image"\s*:\s*"ghcr\.io/eclipse-score/devcontainer:v' + - path: .devcontainer/devcontainer.json + pattern: '"image"\s*:\s*"ghcr\.io/eclipse-score/devcontainer:v' +ensure: + - type: migrate_devcontainer_json + sources: + - .devcontainer.json + - .devcontainer/devcontainer.json + destination: .devcontainer/devcontainer.json + dockerfile: .devcontainer/Dockerfile + image: ghcr.io/eclipse-score/devcontainer + dockerfile_comment: "# Use Dockerfile to get dependabot version bumps after new image is released" + copyright_header_source: copyright-header + copyright_header_organization: eclipse-score + rationale: Dependabot can update the SCORE development image in Dockerfiles. diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.devcontainer/Dockerfile new file mode 100644 index 0000000..6028973 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.devcontainer/run-tool b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.devcontainer/run-tool new file mode 100755 index 0000000..7821981 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.devcontainer/run-tool @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Unified entry point for running a CLI tool by name. +# Inside a container the tool is expected on PATH; outside, it is resolved via Bazel. +# See https://github.com/eclipse-score/devcontainer/tree/main/tools for further information. +# +# Do not modify this file. Its source of truth is managed by the +# SCORE devcontainer standardization policy. + +set -euo pipefail + +if [[ "$#" -lt 1 ]]; then + echo "Usage: $0 [args...]" >&2 + exit 2 +fi + +tool_name="$1" +shift + +if { [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]] || [[ -d /devcontainer ]]; } && + command -v "${tool_name}" >/dev/null 2>&1; then + exec "${tool_name}" "$@" +elif command -v bazel >/dev/null 2>&1; then + exec bazel run "@score_devcontainer//tools:${tool_name}" -- "$@" +else + echo "Could not run '${tool_name}': not available on PATH in a container, and bazel was not found." >&2 + exit 127 +fi diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.pre-commit-config.yaml b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.pre-commit-config.yaml new file mode 100644 index 0000000..73d8992 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +repos: + - repo: local + hooks: + - id: actionlint + entry: .devcontainer/run-tool actionlint diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/MODULE.bazel b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/MODULE.bazel new file mode 100644 index 0000000..8bb0caf --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/MODULE.bazel @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") + +bazel_dep( + name = "score_devcontainer", + version = "1.9.0", +) diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.devcontainer/Dockerfile new file mode 100644 index 0000000..6028973 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.devcontainer/run_tool.sh b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.devcontainer/run_tool.sh new file mode 100755 index 0000000..0c46c77 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.devcontainer/run_tool.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +echo previous launcher path diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.pre-commit-config.yaml b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.pre-commit-config.yaml new file mode 100644 index 0000000..d4bb5b4 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +repos: + - repo: local + hooks: + - id: actionlint + entry: .devcontainer/run_tool.sh actionlint diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/MODULE.bazel b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/MODULE.bazel new file mode 100644 index 0000000..ed46691 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/MODULE.bazel @@ -0,0 +1,14 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.devcontainer/Dockerfile new file mode 100644 index 0000000..6028973 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.devcontainer/run-tool b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.devcontainer/run-tool new file mode 100755 index 0000000..7821981 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.devcontainer/run-tool @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Unified entry point for running a CLI tool by name. +# Inside a container the tool is expected on PATH; outside, it is resolved via Bazel. +# See https://github.com/eclipse-score/devcontainer/tree/main/tools for further information. +# +# Do not modify this file. Its source of truth is managed by the +# SCORE devcontainer standardization policy. + +set -euo pipefail + +if [[ "$#" -lt 1 ]]; then + echo "Usage: $0 [args...]" >&2 + exit 2 +fi + +tool_name="$1" +shift + +if { [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]] || [[ -d /devcontainer ]]; } && + command -v "${tool_name}" >/dev/null 2>&1; then + exec "${tool_name}" "$@" +elif command -v bazel >/dev/null 2>&1; then + exec bazel run "@score_devcontainer//tools:${tool_name}" -- "$@" +else + echo "Could not run '${tool_name}': not available on PATH in a container, and bazel was not found." >&2 + exit 127 +fi diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.pre-commit-config.yaml b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.pre-commit-config.yaml new file mode 100644 index 0000000..09f0ce7 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +repos: + - repo: local + hooks: + - id: actionlint + entry: .devcontainer/run-tool actionlint + - id: ruff + entry: .devcontainer/run-tool ruff check diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/MODULE.bazel b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/MODULE.bazel new file mode 100644 index 0000000..506554c --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/MODULE.bazel @@ -0,0 +1,15 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_devcontainer", version = "1.9.0") diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/.devcontainer/Dockerfile new file mode 100644 index 0000000..6028973 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/.pre-commit-config.yaml b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/.pre-commit-config.yaml new file mode 100644 index 0000000..da0888d --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +repos: + - repo: local + hooks: + - id: actionlint + entry: tools/run_tool.sh actionlint + - id: ruff + entry: tools/run_tool.sh ruff check diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/MODULE.bazel b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/MODULE.bazel new file mode 100644 index 0000000..506554c --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/MODULE.bazel @@ -0,0 +1,15 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_devcontainer", version = "1.9.0") diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/tools/run_tool.sh b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/tools/run_tool.sh new file mode 100755 index 0000000..55f3ac3 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/tools/run_tool.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +echo legacy diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/policy.yml b/repo_policy_sync/policies/score-devcontainer-standardization/policy.yml new file mode 100644 index 0000000..afa5d9e --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/policy.yml @@ -0,0 +1,49 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +title: "chore(devcontainer): standardize SCORE devcontainer integration" +description: | + Add the SCORE devcontainer's direct Bazel dependency and provide the standard + SCORE tool launcher and paths. +when: + file_exists: MODULE.bazel + file_contains: + path: .devcontainer/Dockerfile + pattern: '(?m)^\s*FROM\s+ghcr\.io/eclipse-score/devcontainer:' +ensure: + - type: ensure_bazel_dependency + dockerfile: .devcontainer/Dockerfile + module_file: MODULE.bazel + image: ghcr.io/eclipse-score/devcontainer + module_name: score_devcontainer + rationale: Declare the SCORE devcontainer as a direct Bazel dependency. + - type: synchronize_file + path: .devcontainer/run-tool + source: run-tool + executable: true + rationale: Provide the standard SCORE tool launcher in repositories using the SCORE devcontainer. + - type: replace_regex + path: .pre-commit-config.yaml + pattern: '(? [args...]" >&2 + exit 2 +fi + +tool_name="$1" +shift + +if { [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]] || [[ -d /devcontainer ]]; } && + command -v "${tool_name}" >/dev/null 2>&1; then + exec "${tool_name}" "$@" +elif command -v bazel >/dev/null 2>&1; then + exec bazel run "@score_devcontainer//tools:${tool_name}" -- "$@" +else + echo "Could not run '${tool_name}': not available on PATH in a container, and bazel was not found." >&2 + exit 127 +fi diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/after/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/after/.devcontainer/Dockerfile new file mode 100644 index 0000000..6028973 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/after/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/after/MODULE.bazel b/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/after/MODULE.bazel new file mode 100644 index 0000000..506554c --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/after/MODULE.bazel @@ -0,0 +1,15 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_devcontainer", version = "1.9.0") diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/before/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/before/.devcontainer/Dockerfile new file mode 100644 index 0000000..6028973 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/before/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/before/MODULE.bazel b/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/before/MODULE.bazel new file mode 100644 index 0000000..506554c --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/before/MODULE.bazel @@ -0,0 +1,15 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") +bazel_dep(name = "score_devcontainer", version = "1.9.0") diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/after/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/after/.devcontainer/Dockerfile new file mode 100644 index 0000000..9a6c8aa --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/after/.devcontainer/Dockerfile @@ -0,0 +1,3 @@ +FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 AS builder + +RUN echo build diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/after/MODULE.bazel b/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/after/MODULE.bazel new file mode 100644 index 0000000..8bb0caf --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/after/MODULE.bazel @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") + +bazel_dep( + name = "score_devcontainer", + version = "1.9.0", +) diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/before/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/before/.devcontainer/Dockerfile new file mode 100644 index 0000000..9a6c8aa --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/before/.devcontainer/Dockerfile @@ -0,0 +1,3 @@ +FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 AS builder + +RUN echo build diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/before/MODULE.bazel b/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/before/MODULE.bazel new file mode 100644 index 0000000..f95cdba --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/before/MODULE.bazel @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") + +bazel_dep( + name = "score_devcontainer", + version = "1.8.4", +) diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/after/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/after/.devcontainer/Dockerfile new file mode 100644 index 0000000..a3646b7 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/after/.devcontainer/Dockerfile @@ -0,0 +1,3 @@ +FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 AS development + +RUN echo ready diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/after/MODULE.bazel b/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/after/MODULE.bazel new file mode 100644 index 0000000..3e5ca2a --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/after/MODULE.bazel @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") + +bazel_dep( + version = "1.9.0", + name = "score_devcontainer", +) diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/before/.devcontainer/Dockerfile b/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/before/.devcontainer/Dockerfile new file mode 100644 index 0000000..ed3f9cf --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/before/.devcontainer/Dockerfile @@ -0,0 +1,3 @@ +FROM ghcr.io/eclipse-score/devcontainer:v1.8.4 AS development + +RUN echo ready diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/before/MODULE.bazel b/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/before/MODULE.bazel new file mode 100644 index 0000000..3e5ca2a --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/before/MODULE.bazel @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "example") + +bazel_dep( + version = "1.9.0", + name = "score_devcontainer", +) diff --git a/repo_policy_sync/policies/score-devcontainer-version-alignment/policy.yml b/repo_policy_sync/policies/score-devcontainer-version-alignment/policy.yml new file mode 100644 index 0000000..e949d13 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-version-alignment/policy.yml @@ -0,0 +1,34 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +title: "chore(devcontainer): align SCORE devcontainer versions" +description: | + Keep the SCORE devcontainer image and its direct Bazel dependency on the same + version. This policy is intentionally limited to recurring version updates. +when: + bazel: + direct_module_dependencies: [score_devcontainer] + file_contains: + path: .devcontainer/Dockerfile + pattern: '(?m)^\s*FROM\s+ghcr\.io/eclipse-score/devcontainer:' +ensure: + - type: synchronize_devcontainer_version + dockerfile: .devcontainer/Dockerfile + module_file: MODULE.bazel + image: ghcr.io/eclipse-score/devcontainer + module_name: score_devcontainer + rationale: Keep the development image and its Bazel integration on a compatible version. +after_apply: + - command: [bazel, mod, deps] + when_file_exists: MODULE.bazel.lock + when_path_changed: MODULE.bazel + description: Regenerate MODULE.bazel.lock with `bazel mod deps`. diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/after/.github/workflows/docs-publish.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/after/.github/workflows/docs-publish.yml new file mode 100644 index 0000000..195606f --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/after/.github/workflows/docs-publish.yml @@ -0,0 +1,31 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +name: Publish Documentation + +on: + workflow_run: + workflows: ["Documentation CI"] + types: [completed] + +jobs: + docs-publish: + if: github.event.workflow_run.conclusion == 'success' + uses: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 + with: + deployment_type: workflow + permissions: + actions: write + contents: write + id-token: write + pages: write + pull-requests: write diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/after/.github/workflows/docs.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/after/.github/workflows/docs.yml new file mode 100644 index 0000000..1061404 --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/after/.github/workflows/docs.yml @@ -0,0 +1,36 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +permissions: + contents: read + +name: Documentation CI +on: + pull_request: + push: + branches: + - main + tags: + - "v*" + release: + types: [published] + merge_group: + types: [checks_requested] + workflow_dispatch: +jobs: + docs: + uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 + with: + retention-days: 3 + permissions: + contents: read + actions: write diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/before/.github/workflows/docs-publish.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/before/.github/workflows/docs-publish.yml new file mode 100644 index 0000000..3d963ed --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/before/.github/workflows/docs-publish.yml @@ -0,0 +1,29 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +name: Publish Documentation +on: + workflow_run: + workflows: ["Documentation CI"] + types: [completed] +jobs: + docs-publish: + if: github.event.workflow_run.conclusion == 'success' + uses: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 + with: + deployment_type: workflow + permissions: + actions: read + contents: write + id-token: write + pages: write + pull-requests: write diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/before/.github/workflows/docs.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/before/.github/workflows/docs.yml new file mode 100644 index 0000000..900aab9 --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/before/.github/workflows/docs.yml @@ -0,0 +1,29 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +name: Documentation CI +on: + pull_request: + push: + branches: + - main + tags: + - "v*" + release: + types: [published] + merge_group: + types: [checks_requested] +jobs: + docs: + uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 + with: + retention-days: 3 diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/docs-publish.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/docs-publish.yml new file mode 100644 index 0000000..c3b4369 --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/docs-publish.yml @@ -0,0 +1,39 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +name: Publish Documentation + +on: + workflow_run: + workflows: ["Documentation"] + types: [completed] + +jobs: + docs-publish: + # Only publish for events that produce a real, addressable doc version. + # merge_group runs are ephemeral (merge-queue checks) and would otherwise + # cause unintended Pages publishes / preview-folder churn. + if: | + github.event.workflow_run.conclusion == 'success' && + contains(fromJSON('["pull_request", "push", "release"]'), github.event.workflow_run.event) + uses: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 # v0.0.3 + permissions: + actions: write + contents: write + id-token: write + pages: write + pull-requests: write + concurrency: + group: docs-publish + cancel-in-progress: false + queue: max diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml new file mode 100644 index 0000000..635f737 --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml @@ -0,0 +1,36 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +name: Documentation + +permissions: + contents: read + +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches: + - main + merge_group: + types: [checks_requested] + release: + types: [published] + workflow_dispatch: + +jobs: + docs-build: + uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 # v0.0.3 + permissions: + contents: read + actions: write diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/after/.github/workflows/docs-publish.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/after/.github/workflows/docs-publish.yml new file mode 100644 index 0000000..c3b4369 --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/after/.github/workflows/docs-publish.yml @@ -0,0 +1,39 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +name: Publish Documentation + +on: + workflow_run: + workflows: ["Documentation"] + types: [completed] + +jobs: + docs-publish: + # Only publish for events that produce a real, addressable doc version. + # merge_group runs are ephemeral (merge-queue checks) and would otherwise + # cause unintended Pages publishes / preview-folder churn. + if: | + github.event.workflow_run.conclusion == 'success' && + contains(fromJSON('["pull_request", "push", "release"]'), github.event.workflow_run.event) + uses: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 # v0.0.3 + permissions: + actions: write + contents: write + id-token: write + pages: write + pull-requests: write + concurrency: + group: docs-publish + cancel-in-progress: false + queue: max diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/after/.github/workflows/docs.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/after/.github/workflows/docs.yml new file mode 100644 index 0000000..de9cd88 --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/after/.github/workflows/docs.yml @@ -0,0 +1,42 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +name: Documentation +on: + pull_request_target: + types: [opened, reopened, synchronize] + push: + branches: + - main + pull_request: + types: [opened, reopened, synchronize] + merge_group: + types: [checks_requested] + release: + types: [published] + workflow_dispatch: +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - run: echo lint + build-docs: + uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 + with: + bazel-target: "--lockfile_mode=error //:docs" + tests-report-artifact: custom-report + permissions: + contents: read + actions: write diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/before/.github/workflows/docs.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/before/.github/workflows/docs.yml new file mode 100644 index 0000000..6d8410c --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/before/.github/workflows/docs.yml @@ -0,0 +1,33 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +name: Documentation +on: + pull_request_target: + types: [opened, reopened, synchronize] + push: + branches: + - main +permissions: + contents: write + pages: write + pull-requests: write +jobs: + lint: + runs-on: ubuntu-latest + steps: + - run: echo lint + build-docs: + uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@v0.0.2 + with: + bazel-target: "--lockfile_mode=error //:docs" + tests-report-artifact: custom-report diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/after/.github/workflows/docs-publish.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/after/.github/workflows/docs-publish.yml new file mode 100644 index 0000000..c3b4369 --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/after/.github/workflows/docs-publish.yml @@ -0,0 +1,39 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +name: Publish Documentation + +on: + workflow_run: + workflows: ["Documentation"] + types: [completed] + +jobs: + docs-publish: + # Only publish for events that produce a real, addressable doc version. + # merge_group runs are ephemeral (merge-queue checks) and would otherwise + # cause unintended Pages publishes / preview-folder churn. + if: | + github.event.workflow_run.conclusion == 'success' && + contains(fromJSON('["pull_request", "push", "release"]'), github.event.workflow_run.event) + uses: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 # v0.0.3 + permissions: + actions: write + contents: write + id-token: write + pages: write + pull-requests: write + concurrency: + group: docs-publish + cancel-in-progress: false + queue: max diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/after/.github/workflows/docs.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/after/.github/workflows/docs.yml new file mode 100644 index 0000000..2ffa7ff --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/after/.github/workflows/docs.yml @@ -0,0 +1,34 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +permissions: + contents: read + +name: Documentation +on: + pull_request_target: + pull_request: + types: [opened, reopened, synchronize] + push: + branches: + - main + merge_group: + types: [checks_requested] + release: + types: [published] + workflow_dispatch: +jobs: + docs: + uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@v0.0.4 + permissions: + contents: read + actions: write diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/before/.github/workflows/docs.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/before/.github/workflows/docs.yml new file mode 100644 index 0000000..e530624 --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/before/.github/workflows/docs.yml @@ -0,0 +1,18 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +name: Documentation +on: + pull_request_target: +jobs: + docs: + uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@v0.0.4 diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/policy.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/policy.yml new file mode 100644 index 0000000..860fade --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/policy.yml @@ -0,0 +1,38 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +title: "chore(ci): align SCORE documentation workflows" +description: | + Use the shared SCORE documentation build and publish workflows as documented + by cicd-workflows. Keep repository-specific workflow refs when they are at + least the policy baseline or cannot be safely ordered. +when: + file_contains_any: + - path: .github/workflows/*.yml + pattern: '(?m)^\s*uses:\s+eclipse-score/cicd-workflows/\.github/workflows/docs\.yml@' + - path: .github/workflows/*.yaml + pattern: '(?m)^\s*uses:\s+eclipse-score/cicd-workflows/\.github/workflows/docs\.yml@' +ensure: + - type: synchronize_workflow + source: docs.yml + reusable_workflow: eclipse-score/cicd-workflows/.github/workflows/docs.yml + minimum_version: 0.0.3 + required_triggers: + - pull_request + - push + - merge_group + - release + - workflow_dispatch + workflow_run: + path: .github/workflows/docs-publish.yml + source: docs-publish.yml + rationale: Use the documented unprivileged documentation build workflow. diff --git a/repo_policy_sync/policy.py b/repo_policy_sync/policy.py new file mode 100644 index 0000000..3fa6031 --- /dev/null +++ b/repo_policy_sync/policy.py @@ -0,0 +1,389 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Loading and validating policy YAML files.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + +from .errors import PolicyError +from .bazel import parse_bazel_dependency_condition +from .models import ( + AfterApplyCommand, + BazelCondition, + BazelDependencyCondition, + FileContainsAnyCondition, + FileContainsCondition, + FileExistsCondition, + Policy, + policy_branch_slug, +) +from .operations import parse_operation +from .operations._validation import safe_relative_path + +# Policy definitions belong to the consuming repository. Keep the default +# relative to the caller's working directory so `./policies` is enough. +DEFAULT_POLICY_DIRECTORY = Path("policies") +BUNDLED_POLICY_DIRECTORY = Path(__file__).with_name("policies") + + +def load_policies(paths: tuple[Path, ...] | None = None) -> tuple[Policy, ...]: + """Load explicitly selected policies or the default policy catalogue.""" + + selected_paths = ( + discover_policy_paths(DEFAULT_POLICY_DIRECTORY) if paths is None else paths + ) + policies = tuple(load_policy(path) for path in selected_paths) + identifiers = [policy.id for policy in policies] + duplicates = sorted( + {identifier for identifier in identifiers if identifiers.count(identifier) > 1} + ) + if duplicates: + raise PolicyError(f"policy IDs must be unique: {', '.join(duplicates)}") + branches: dict[str, str] = {} + for policy in policies: + slug = policy_branch_slug(policy.id) + if not slug: + raise PolicyError(f"policy ID cannot produce a branch name: {policy.id!r}") + previous = branches.get(slug) + if previous is not None and previous != policy.id: + raise PolicyError( + f"policy IDs {previous!r} and {policy.id!r} map to the same " + f"policy branch slug {slug!r}" + ) + branches[slug] = policy.id + return policies + + +def resolve_policy_names( + names: tuple[str, ...], + directory: Path | tuple[Path, ...] = DEFAULT_POLICY_DIRECTORY, +) -> tuple[Path, ...]: + """Resolve policy directory names across one or more policy directories.""" + + available_paths = tuple( + sorted( + { + path + for policy_directory in _policy_directories(directory) + for path in discover_policy_paths(policy_directory) + }, + key=str, + ) + ) + paths_by_name: dict[str, Path] = {} + for path in available_paths: + policy = load_policy(path) + name = policy.id + if name in paths_by_name and paths_by_name[name] != path: + raise PolicyError(f"policy ID is not unique: {name}") + paths_by_name[name] = path + unknown_names = sorted(set(names) - set(paths_by_name)) + if unknown_names: + available_names = ", ".join(sorted(paths_by_name)) + raise PolicyError( + f"unknown policy name(s): {', '.join(unknown_names)}; " + f"available: {available_names}" + ) + return tuple(paths_by_name[name] for name in names) + + +def _policy_directories(directory: Path | tuple[Path, ...]) -> tuple[Path, ...]: + return (directory,) if isinstance(directory, Path) else directory + + +def discover_policy_paths(directory: Path) -> tuple[Path, ...]: + """Find policy definitions in deterministic policy-directory order.""" + + if not directory.is_dir(): + raise PolicyError(f"policy directory does not exist: {directory}") + paths = tuple(sorted(directory.rglob("policy.yml"))) + if not paths: + raise PolicyError(f"policy directory contains no YAML files: {directory}") + return paths + + +def load_policy(path: Path) -> Policy: + """Load one policy file using the intentionally small policy schema.""" + + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except OSError as exc: + raise PolicyError(f"could not read policy {path}: {exc}") from exc + except UnicodeError as exc: + raise PolicyError(f"could not decode policy {path} as UTF-8: {exc}") from exc + except yaml.YAMLError as exc: + raise PolicyError(f"invalid YAML in policy {path}: {exc}") from exc + if not isinstance(raw, dict): + raise PolicyError(f"policy {path} must contain a YAML mapping") + _expect_keys( + raw, + {"title", "description", "when", "ensure", "after_apply"}, + path, + ) + + policy_id = path.parent.name + if not policy_id: + raise PolicyError( + f"policy {path}: policy.yml must be inside a named policy directory" + ) + title = _required_string(raw, "title", path) + description = _optional_string(raw, "description", path) + ( + bazel_condition, + file_exists_condition, + file_contains_condition, + file_contains_any_condition, + ) = _parse_condition(raw.get("when"), path) + ensure_raw = raw.get("ensure") + if not isinstance(ensure_raw, list) or not ensure_raw: + raise PolicyError(f"policy {path}: ensure must be a non-empty list") + ensure = tuple(parse_operation(item, path) for item in ensure_raw) + after_apply = _parse_after_apply(raw.get("after_apply", []), path) + return Policy( + id=policy_id, + title=title, + description=description, + bazel_condition=bazel_condition, + ensure=ensure, + after_apply=after_apply, + file_exists_condition=file_exists_condition, + file_contains_condition=file_contains_condition, + file_contains_any_condition=file_contains_any_condition, + ) + + +def _parse_condition( + raw: object, source: Path +) -> tuple[ + BazelCondition | None, + FileExistsCondition | None, + FileContainsCondition | None, + FileContainsAnyCondition | None, +]: + if raw is None: + return None, None, None, None + if not isinstance(raw, dict): + raise PolicyError(f"policy {source}: when must be a mapping") + if ( + not set(raw).issubset( + {"bazel", "file_exists", "file_contains", "file_contains_any"} + ) + or not raw + ): + raise PolicyError( + f"policy {source}: only when.bazel, when.file_exists, when.file_contains, " + "and when.file_contains_any are supported" + ) + bazel_condition = None + if "bazel" in raw: + bazel = raw["bazel"] + if ( + not isinstance(bazel, dict) + or not set(bazel).issubset( + { + "direct_module_dependencies", + "any_direct_module_dependencies", + "any_direct_module_conditions", + } + ) + or not bazel + ): + raise PolicyError( + f"policy {source}: bazel must contain only direct_module_dependencies, " + "any_direct_module_dependencies, and any_direct_module_conditions" + ) + # Missing fields become empty lists so either kind of dependency check + # can be used on its own. + dependencies = _string_list( + bazel.get("direct_module_dependencies", []), + "when.bazel.direct_module_dependencies", + source, + ) + any_dependencies = _string_list( + bazel.get("any_direct_module_dependencies", []), + "when.bazel.any_direct_module_dependencies", + source, + ) + any_conditions = _parse_bazel_dependency_conditions( + bazel.get("any_direct_module_conditions", []), source + ) + if not dependencies and not any_dependencies and not any_conditions: + raise PolicyError( + f"policy {source}: direct_module_dependencies or " + "any_direct_module_dependencies or any_direct_module_conditions " + "must not be empty" + ) + bazel_condition = BazelCondition(dependencies, any_dependencies, any_conditions) + file_exists_condition = None + if "file_exists" in raw: + file_exists = raw["file_exists"] + if not isinstance(file_exists, str) or not file_exists.strip(): + raise PolicyError( + f"policy {source}: when.file_exists must be a non-empty path" + ) + file_exists_condition = FileExistsCondition( + safe_relative_path(file_exists, source) + ) + file_contains_condition = None + if "file_contains" in raw: + file_contains_condition = _parse_file_contains_condition( + raw["file_contains"], source + ) + file_contains_any_condition = None + if "file_contains_any" in raw: + file_contains_any = raw["file_contains_any"] + if not isinstance(file_contains_any, list) or not file_contains_any: + raise PolicyError( + f"policy {source}: file_contains_any must be a non-empty list" + ) + file_contains_any_condition = FileContainsAnyCondition( + tuple( + _parse_file_contains_condition(item, source) + for item in file_contains_any + ) + ) + return ( + bazel_condition, + file_exists_condition, + file_contains_condition, + file_contains_any_condition, + ) + + +def _parse_file_contains_condition(raw: object, source: Path) -> FileContainsCondition: + if not isinstance(raw, dict) or set(raw) != {"path", "pattern"}: + raise PolicyError( + f"policy {source}: file condition must contain only path and pattern" + ) + pattern = _required_string(raw, "pattern", source) + try: + re.compile(pattern) + except re.error as exc: + raise PolicyError( + f"policy {source}: invalid file_contains pattern: {exc}" + ) from exc + return FileContainsCondition( + safe_relative_path(_required_string(raw, "path", source), source), pattern + ) + + +def _parse_bazel_dependency_conditions( + raw: object, source: Path +) -> tuple[BazelDependencyCondition, ...]: + if raw == []: + return () + if not isinstance(raw, list) or not raw: + raise PolicyError( + f"policy {source}: when.bazel.any_direct_module_conditions must be a " + "non-empty list" + ) + conditions: list[BazelDependencyCondition] = [] + for index, item in enumerate(raw): + if not isinstance(item, str) or not item.strip(): + raise PolicyError( + f"policy {source}: when.bazel.any_direct_module_conditions[{index}] " + "must be a non-empty condition string" + ) + condition = parse_bazel_dependency_condition(item) + if condition is None: + raise PolicyError( + f"policy {source}: when.bazel.any_direct_module_conditions[{index}] " + "must use 'module OP major.minor.patch' syntax" + ) + conditions.append(condition) + return tuple(conditions) + + +def _parse_after_apply(raw: object, source: Path) -> tuple[AfterApplyCommand, ...]: + if not isinstance(raw, list): + raise PolicyError(f"policy {source}: after_apply must be a list") + commands: list[AfterApplyCommand] = [] + for item in raw: + if ( + not isinstance(item, dict) + or not set(item).issubset( + {"command", "when_file_exists", "when_path_changed", "description"} + ) + or not {"command", "when_file_exists", "description"}.issubset(item) + ): + raise PolicyError( + f"policy {source}: each after_apply item must contain command, when_file_exists, and description" + ) + command = item["command"] + if ( + not isinstance(command, list) + or not command + or not all( + isinstance(argument, str) and argument.strip() for argument in command + ) + ): + raise PolicyError( + f"policy {source}: after_apply command must be a non-empty list of strings" + ) + when_file_exists = safe_relative_path( + _required_string(item, "when_file_exists", source), source + ) + when_path_changed = ( + safe_relative_path( + _required_string(item, "when_path_changed", source), source + ) + if "when_path_changed" in item + else None + ) + description = _required_string(item, "description", source) + commands.append( + AfterApplyCommand( + tuple(command), when_file_exists, description, when_path_changed + ) + ) + return tuple(commands) + + +def _expect_keys(value: dict[str, Any], allowed: set[str], source: Path) -> None: + unexpected = set(value) - allowed + if unexpected: + raise PolicyError( + f"policy {source}: unexpected fields: {', '.join(sorted(unexpected))}" + ) + + +def _required_string(value: dict[str, Any], key: str, source: Path) -> str: + result = value.get(key) + if not isinstance(result, str) or not result.strip(): + raise PolicyError(f"policy {source}: {key} must be a non-empty string") + return result + + +def _optional_string(value: dict[str, Any], key: str, source: Path) -> str | None: + result = value.get(key) + if result is None: + return None + if not isinstance(result, str) or not result.strip(): + raise PolicyError(f"policy {source}: {key} must be a non-empty string") + return result + + +def _string_list(value: object, name: str, source: Path) -> tuple[str, ...]: + if not isinstance(value, list) or not all( + isinstance(item, str) and item.strip() for item in value + ): + raise PolicyError( + f"policy {source}: {name} must be a list of non-empty strings" + ) + return tuple(value) diff --git a/repo_policy_sync/reporting.py b/repo_policy_sync/reporting.py new file mode 100644 index 0000000..4f75f1d --- /dev/null +++ b/repo_policy_sync/reporting.py @@ -0,0 +1,578 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Render stable human- and machine-readable policy run reports.""" + +from __future__ import annotations + +import json +import shutil +import unicodedata +from collections import Counter + +from .models import Change +from .runner import RepositoryOutcome, RunReport, RunSummary + + +def render_table(report: RunReport) -> str: + """Render a concise table suitable for interactive terminal use.""" + + rows = [ + ( + outcome.policy_id, + outcome.repository, + _status_label(outcome.status), + _actions_label(outcome), + ) + for outcome in report.outcomes + ] + if not rows: + rows.append(("—", "—", "—", "No policy evaluations.")) + + lines = [ + "📋 Policy evaluations", + _render_box_table( + ("Policy", "Repository", "Status", "Actions"), + rows, + column_limits=(24, 24, 32, 48), + ), + ] + lines.extend(_summary_lines(report)) + return "\n".join(lines) + + +def render_json(report: RunReport) -> str: + """Render the complete report as a versioned JSON document.""" + + summary = report.summary + return json.dumps( + { + "schema_version": 2, + "summary": { + "repositories": summary.repositories, + "synchronized": summary.synchronized, + "sync_failures": summary.sync_failures, + "skipped": summary.skipped, + "evaluations": summary.evaluations, + "compliant": summary.compliant, + "drifted": summary.drifted, + "not_applicable": summary.not_applicable, + "evaluation_failures": summary.evaluation_failures, + "pull_requests_created": summary.pull_requests_created, + "pull_requests_updated": summary.pull_requests_updated, + "pull_requests_open": summary.pull_requests_open, + "pull_requests_recreated": summary.pull_requests_recreated, + "pull_requests_closed": summary.pull_requests_closed, + "duration_seconds": summary.duration_seconds, + }, + "outcomes": [ + { + "policy_id": outcome.policy_id, + "repository": outcome.repository, + "applicable": outcome.when, + "status": outcome.status, + "changes": [_change_to_json(change) for change in outcome.changes], + "pull_request_url": outcome.pull_request_url, + "policy_pr_status": outcome.policy_pr_status, + "warnings": list(outcome.warnings), + "error": outcome.error, + } + for outcome in report.outcomes + ], + }, + indent=2, + sort_keys=True, + ) + + +def render_markdown(report: RunReport) -> str: + """Render a compact repository-by-policy matrix for Markdown consumers.""" + + compliance_counts = Counter( + _markdown_compliance_status(outcome) for outcome in report.outcomes + ) + pull_request_counts = Counter( + outcome.policy_pr_status or "not checked" for outcome in report.outcomes + ) + lines = [ + "# Repository policy compliance", + "", + "## Summary", + "", + f"`{report.summary.repositories}` repositories · " + f"`{report.summary.evaluations}` evaluations · " + f"`{report.summary.duration_seconds:.1f}s`", + "", + f"- ✅ Compliant: `{compliance_counts['yes']}`", + f"- ❌ Changes needed: `{compliance_counts['no']}`", + f"- N/A Not applicable: `{compliance_counts['not applicable']}`", + f"- ⏭️ Not evaluated: `{compliance_counts['not evaluated']}`", + f"- ⚠️ Errors: `{compliance_counts['error']}`", + "", + "- Pull requests: " + f"`🔄 {pull_request_counts['open']} open` · " + f"`🔗 {pull_request_counts['merged']} merged` · " + f"`✅ {pull_request_counts['closed']} closed` · " + f"`— {pull_request_counts['none']} none` · " + f"`? {pull_request_counts['not checked']} not checked`", + "", + "## Compliance matrix", + "", + ] + lines.extend(_markdown_matrix(report.outcomes)) + lines.extend( + [ + "", + "Legend: ✅ compliant · ❌ changes needed · N/A not applicable · " + "⏭️ not evaluated · ⚠️ error · GitHub badge `Open`/`Merged`/`Closed` = PR state", + ] + ) + lines.extend(_markdown_details_section(report.outcomes)) + return "\n".join(lines) + + +def _change_to_json(change: Change) -> dict[str, str | None]: + return { + "path": str(change.path), + "description": change.description, + "rationale": change.rationale, + } + + +def _markdown_compliance_status(outcome: RepositoryOutcome) -> str: + if outcome.status in {"compliant", "pull-request-closed"}: + return "yes" + if outcome.status == "not-applicable": + return "not applicable" + if outcome.status in {"skipped", "sync-error"}: + return "not evaluated" + if outcome.status == "error": + return "error" + return "no" + + +def _markdown_matrix(outcomes: tuple[RepositoryOutcome, ...]) -> list[str]: + """Render one row per repository and one column per policy.""" + + repositories = tuple(dict.fromkeys(outcome.repository for outcome in outcomes)) + policies = tuple(dict.fromkeys(outcome.policy_id for outcome in outcomes)) + if not repositories or not policies: + return ["_No repository/policy evaluations._"] + + by_pair = {(outcome.repository, outcome.policy_id): outcome for outcome in outcomes} + lines = [ + "| Repository | " + + " | ".join(_markdown_cell(policy) for policy in policies) + + " |", + "| --- | " + " | ".join("---" for _ in policies) + " |", + ] + for repository in repositories: + cells = [ + _markdown_matrix_cell(by_pair.get((repository, policy))) + for policy in policies + ] + lines.append("| " + " | ".join((_markdown_cell(repository), *cells)) + " |") + return lines + + +def _markdown_matrix_cell(outcome: RepositoryOutcome | None) -> str: + if outcome is None: + return "N/A" + + compliance = _markdown_compliance_status(outcome) + if compliance == "yes": + status = "✅" + elif compliance == "no": + status = "❌" + elif compliance == "not applicable": + status = "N/A" + elif compliance == "not evaluated": + status = "⏭️" + else: + status = "⚠️" + + parts = [status] + if outcome.policy_pr_status in {"open", "merged", "closed"}: + pr_label = { + "open": "🔄 open PR", + "merged": "🔗 merged PR", + "closed": "✅ closed PR", + }[outcome.policy_pr_status] + if outcome.pull_request_url: + parts.append( + _markdown_pr_badge(outcome.policy_pr_status, outcome.pull_request_url) + ) + else: + parts.append(pr_label) + return _markdown_cell(" ".join(parts)) + + +def _markdown_pr_badge(status: str, url: str) -> str: + """Render a GitHub-logo status badge linking to the policy pull request.""" + + label, color = { + "open": ("Open", "2ea043"), + "merged": ("Merged", "8250df"), + "closed": ("Closed", "6e7781"), + }[status] + badge_url = ( + f"https://img.shields.io/badge/-{label}-{color}" + "?style=flat&logo=github&logoColor=white" + ) + return f"[![{label} PR]({badge_url})]({_markdown_url(url)})" + + +def _markdown_details_section(outcomes: tuple[RepositoryOutcome, ...]) -> list[str]: + detailed = tuple( + outcome for outcome in outcomes if _markdown_details(outcome) != "—" + ) + if not detailed: + return [] + + lines = [ + "", + "
", + f"Details ({len(detailed)})", + "", + ] + for outcome in detailed: + lines.append( + f"- `{_markdown_cell(outcome.repository)}` / " + f"`{_markdown_cell(outcome.policy_id)}`: " + f"{_markdown_cell(_markdown_details(outcome))}" + ) + lines.extend(["", "
"]) + return lines + + +def _markdown_details(outcome: RepositoryOutcome) -> str: + if outcome.error: + return outcome.error + if outcome.status == "skipped": + return "No default branch; evaluation skipped." + details = _format_changes(outcome.changes) + if outcome.warnings: + details = "; ".join(part for part in (details, *outcome.warnings) if part) + return details or "—" + + +def _markdown_url(value: str) -> str: + return value.replace("(", "%28").replace(")", "%29").replace("\n", "") + + +def _markdown_cell(value: str) -> str: + return value.replace("|", "\\|").replace("\n", " ") + + +def _summary_lines(report: RunReport) -> list[str]: + summary = report.summary + evaluation_rows = ( + ("Repositories", f"{summary.repositories} selected", ""), + (" ✅ synchronized", str(summary.synchronized), ""), + (" ⚠ sync failed", str(summary.sync_failures), ""), + (" ⏭ skipped (no default branch)", str(summary.skipped), ""), + ("Policy evaluations", str(summary.evaluations), ""), + _summary_row(" ✅", summary.compliant, summary.evaluations), + _summary_row( + f" {_changes_required_marker()}", summary.drifted, summary.evaluations + ), + _summary_row(" ⚪", summary.not_applicable, summary.evaluations), + _summary_row(" ⚠ failed", summary.evaluation_failures, summary.evaluations), + ) + lines = [ + "", + f"📊 Summary · {summary.duration_seconds:.1f}s", + _render_box_table( + ("Area", "Count", "Share"), + evaluation_rows, + column_limits=(36, 18, 12), + ), + ] + if _has_pull_request_activity(summary): + lines.extend( + [ + "", + "🔀 Pull requests", + _render_box_table( + ("State", "Count"), + ( + ("🆕 created", str(summary.pull_requests_created)), + ("✏ updated", str(summary.pull_requests_updated)), + ("🔄 already open", str(summary.pull_requests_open)), + ("♻ recreated", str(summary.pull_requests_recreated)), + ("✅ closed", str(summary.pull_requests_closed)), + ), + column_limits=(28, 18), + ), + ] + ) + sync_failures = _unique_sync_failures(report.outcomes) + lines.extend(_failure_table("⚠ Sync failure causes", sync_failures)) + evaluation_failures = tuple( + outcome for outcome in report.outcomes if outcome.status == "error" + ) + lines.extend( + _failure_table("⚠ Policy evaluation failure causes", evaluation_failures) + ) + return lines + + +def _summary_row(label: str, count: int, total: int) -> tuple[str, str, str]: + percentage = f"{count / total:.1%}" if total else "—" + return label, str(count), percentage + + +def _one_line(value: str) -> str: + return value.replace("\n", " ") + + +def _has_pull_request_activity(summary: RunSummary) -> bool: + return any( + ( + summary.pull_requests_created, + summary.pull_requests_updated, + summary.pull_requests_open, + summary.pull_requests_recreated, + summary.pull_requests_closed, + ) + ) + + +def _unique_sync_failures( + outcomes: tuple[RepositoryOutcome, ...], +) -> tuple[RepositoryOutcome, ...]: + unique: dict[tuple[str, str], RepositoryOutcome] = {} + for outcome in outcomes: + if outcome.status == "sync-error": + unique.setdefault( + (outcome.repository, outcome.error or "unknown error"), outcome + ) + return tuple(unique.values()) + + +def _failure_table(title: str, failures: tuple[RepositoryOutcome, ...]) -> list[str]: + if not failures: + return [] + lines = ["", title] + grouped = Counter( + _one_line(outcome.error or "unknown error") for outcome in failures + ) + rows = [] + for error, count in grouped.most_common(5): + affected = [ + f"{outcome.policy_id}/{outcome.repository}" + for outcome in failures + if _one_line(outcome.error or "unknown error") == error + ] + examples = ", ".join(affected[:3]) + remaining = f", and {len(affected) - 3} more" if len(affected) > 3 else "" + rows.append((str(count), error, f"{examples}{remaining}")) + if len(grouped) > 5: + rows.append(("—", f"{len(grouped) - 5} more distinct failure cause(s)", "")) + lines.append( + _render_box_table( + ("Count", "Cause", "Affected evaluations"), + rows, + column_limits=(10, 52, 44), + ) + ) + return lines + + +def _status_label(status: str) -> str: + return { + "compliant": "✅", + "changes-required": _changes_required_marker(), + "not-applicable": "⚪", + "sync-error": "⚠ sync failed", + "error": "⚠ evaluation failed", + "pull-request-created": "🆕 pull request created", + "pull-request-updated": "✏ pull request updated", + "pull-request-open": "🔄 pull request open", + "pull-request-recreated": "♻ pull request recreated", + "pull-request-recreated-no-changes": "♻ pull request recreated (no changes)", + "pull-request-closed": "✅ pull request closed", + }.get(status, status) + + +def _changes_required_marker() -> str: + return "🔴" + + +def _actions_label(outcome: RepositoryOutcome) -> str: + if outcome.error: + return outcome.error + actions = _format_changes(outcome.changes) + if outcome.pull_request_url: + actions = f"{actions}; PR: {outcome.pull_request_url}" + if outcome.warnings: + actions = f"{actions}; {'; '.join(outcome.warnings)}" + return actions or "-" + + +def _format_changes(changes: tuple[Change, ...]) -> str: + by_path: dict[str, list[str]] = {} + for change in changes: + by_path.setdefault(str(change.path), []).append(change.description) + return "; ".join( + f"{path}: {', '.join(descriptions)}" for path, descriptions in by_path.items() + ) + + +def _render_box_table( + headers: tuple[str, ...], + rows: tuple[tuple[str, ...], ...] | list[tuple[str, ...]], + *, + column_limits: tuple[int, ...], +) -> str: + """Render a Unicode box table with wrapped cells and aligned columns.""" + + if len(headers) != len(column_limits): + raise ValueError("headers and column_limits must have the same length") + column_count = len(headers) + normalized_rows = [tuple(_one_line(value) for value in row) for row in rows] + if any(len(row) != column_count for row in normalized_rows): + raise ValueError("every row must have one value per header") + + terminal_width = shutil.get_terminal_size(fallback=(120, 24)).columns + available_width = max(1, terminal_width - (3 * column_count + 1)) + minimum_widths = tuple( + min(limit, max(8, _display_width(header))) + for header, limit in zip(headers, column_limits, strict=True) + ) + widths = [ + min( + limit, + max( + _display_width(header), + *( + max( + (_display_width(line) for line in value.splitlines()), default=0 + ) + for value in (row[index] for row in normalized_rows) + ), + ), + ) + for index, (header, limit) in enumerate( + zip(headers, column_limits, strict=True) + ) + ] + while sum(widths) > available_width: + shrinkable = [ + index for index, width in enumerate(widths) if width > minimum_widths[index] + ] + if not shrinkable: + break + index = max(shrinkable, key=lambda item: widths[item] - minimum_widths[item]) + widths[index] -= 1 + + # ``widths`` describes the text area. The two spaces added around every + # cell in _wrap_row are part of the rendered table as well. + border_widths = [width + 2 for width in widths] + top = "┌" + "┬".join("─" * width for width in border_widths) + "┐" + separator = "├" + "┼".join("─" * width for width in border_widths) + "┤" + bottom = "└" + "┴".join("─" * width for width in border_widths) + "┘" + rendered_rows = [_wrap_row(headers, widths)] + rendered_rows.extend(_wrap_row(row, widths) for row in normalized_rows) + lines = [top] + for row_index, row_lines in enumerate(rendered_rows): + if row_index: + lines.append(separator) + lines.extend(row_lines) + lines.append(bottom) + return "\n".join(lines) + + +def _wrap_row( + values: tuple[str, ...], widths: list[int] | tuple[int, ...] +) -> list[str]: + wrapped = [ + _wrap_cell(value, width) for value, width in zip(values, widths, strict=True) + ] + lines = [] + for line_index in ( + range(max(len(lines) for lines in wrapped)) if wrapped else range(0) + ): + cells = [ + _pad_display_width( + cell_lines[line_index] if line_index < len(cell_lines) else "", width + ) + for cell_lines, width in zip(wrapped, widths, strict=True) + ] + lines.append("│ " + " │ ".join(cells) + " │") + return lines + + +def _wrap_cell(value: str, width: int) -> list[str]: + """Wrap a cell without exceeding its display width.""" + + if width < 1: + return [""] + lines: list[str] = [] + for raw_line in value.splitlines() or [""]: + remaining = raw_line.strip() + if not remaining: + lines.append("") + continue + while _display_width(remaining) > width: + split_at = _last_space_within(remaining, width) + if split_at <= 0: + split_at = _fit_prefix(remaining, width) + lines.append(remaining[:split_at].rstrip()) + remaining = remaining[split_at:].lstrip() + lines.append(remaining) + return lines or [""] + + +def _last_space_within(value: str, width: int) -> int: + position = 0 + last_space = -1 + for index, character in enumerate(value): + character_width = _display_width(character) + if position + character_width > width: + break + position += character_width + if character.isspace(): + last_space = index + return last_space + + +def _fit_prefix(value: str, width: int) -> int: + position = 0 + for index, character in enumerate(value): + character_width = _display_width(character) + if position + character_width > width: + return max(1, index) + position += character_width + return len(value) + + +def _pad_display_width(value: str, width: int) -> str: + return value + " " * max(0, width - _display_width(value)) + + +def _display_width(value: str) -> int: + """Return a terminal-oriented width for a Unicode string.""" + + width = 0 + for index, character in enumerate(value): + if unicodedata.combining(character) or unicodedata.category(character) in { + "Cf", + "Mn", + }: + continue + if unicodedata.east_asian_width(character) in {"W", "F"}: + width += 2 + else: + width += 1 + return width diff --git a/repo_policy_sync/runner.py b/repo_policy_sync/runner.py new file mode 100644 index 0000000..b2ac3de --- /dev/null +++ b/repo_policy_sync/runner.py @@ -0,0 +1,1003 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Organization-level orchestration for plan and apply runs.""" + +from __future__ import annotations + +import os +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path +from time import monotonic +from typing import Callable, Protocol + +from .engine import apply_policy, evaluate_policy +from .errors import RepoPolicySyncError, redact_sensitive_text +from .github import ( + CommitResult, + PolicyPullRequestStatus, + TOOL_SLUG, + _pull_request_body, + policy_branches, +) +from .models import Change, Policy, Repository + +DEFAULT_SYNC_WORKERS = max(1, os.cpu_count() or 1) +DEFAULT_POLICY_WORKERS = DEFAULT_SYNC_WORKERS + + +class RepositoryClient(Protocol): + """The small gh/Git boundary required by the organization workflow.""" + + def ensure_authenticated(self) -> None: ... + + def list_repositories(self, *, org: str) -> tuple[Repository, ...]: ... + + def sync_default_branch( + self, *, repository: str, branch: str, destination: Path + ) -> None: ... + + def restore_synced_default_branch(self, *, checkout: Path) -> None: ... + + def find_open_pull_request( + self, + *, + repository: str, + branches: tuple[str, ...], + policy_id: str, + ) -> object | None: ... + + def find_policy_pull_request_status( + self, + *, + repository: str, + branches: tuple[str, ...], + policy_id: str, + ) -> PolicyPullRequestStatus: ... + + def switch_to_policy_branch( + self, *, checkout: Path, branch: str, exists_remotely: bool + ) -> None: ... + + def recreate_policy_branch(self, *, checkout: Path, branch: str) -> None: ... + + def verify_policy_branch_head( + self, *, checkout: Path, branch: str, expected_head_oid: str + ) -> None: ... + + def commit_and_push( + self, + *, + checkout: Path, + branch: str, + policy: Policy, + changes: tuple[Change, ...], + allow_dirty_pr: bool = False, + ) -> CommitResult: ... + + def has_changes(self, *, checkout: Path, changes: tuple[Change, ...]) -> bool: ... + + def commit_and_force_push( + self, + *, + checkout: Path, + branch: str, + expected_head_oid: str, + policy: Policy, + changes: tuple[Change, ...], + allow_dirty_pr: bool = False, + ) -> CommitResult: ... + + def create_pull_request( + self, + *, + repository: str, + base: str, + branch: str, + policy: Policy, + changes: tuple[Change, ...], + head_oid: str, + draft: bool = False, + ) -> object: ... + + def update_pull_request( + self, + *, + repository: str, + pull_request: object, + policy: Policy, + changes: tuple[Change, ...], + head_oid: str, + failure: str | None = None, + ) -> None: ... + + def close_pull_request(self, *, repository: str, pull_request: object) -> None: ... + + def mark_pull_request_draft( + self, *, repository: str, pull_request: object + ) -> None: ... + + def comment_on_pull_request( + self, *, repository: str, pull_request: object, failure: str + ) -> None: ... + + +@dataclass(frozen=True) +class RunSummary: + repositories: int + synchronized: int + sync_failures: int + skipped: int + evaluations: int + compliant: int + drifted: int + not_applicable: int + evaluation_failures: int + pull_requests_created: int + pull_requests_updated: int + pull_requests_open: int + pull_requests_recreated: int + pull_requests_closed: int = 0 + duration_seconds: float = 0.0 + + +@dataclass(frozen=True) +class RunReport: + """Complete result of evaluating the selected policy/repository pairs.""" + + summary: RunSummary + outcomes: tuple[RepositoryOutcome, ...] + + +@dataclass(frozen=True) +class RepositoryOutcome: + repository: str + policy_id: str + when: str + status: str + changes: tuple[Change, ...] = () + pull_request_url: str | None = None + warnings: tuple[str, ...] = () + error: str | None = None + policy_pr_status: str | None = None + + +def run_policies( + *, + client: RepositoryClient, + org: str, + policies: tuple[Policy, ...], + repository_names: tuple[str, ...], + checkout_cache_directory: Path, + apply: bool, + recreate: bool = False, + allow_dirty_pr: bool = False, + sync_workers: int = DEFAULT_SYNC_WORKERS, + policy_workers: int = DEFAULT_POLICY_WORKERS, + progress: Callable[[str], None] | None = None, + include_pull_request_status: bool = False, +) -> RunReport: + """Synchronize repositories, then process each policy across repositories in parallel. + + The returned report is independent of presentation so callers can render a + terminal table, machine-readable JSON, or their own integration output. + """ + + if sync_workers < 1: + raise RepoPolicySyncError("sync worker count must be at least 1") + if policy_workers < 1: + raise RepoPolicySyncError("policy worker count must be at least 1") + if recreate and not apply: + raise RepoPolicySyncError("--recreate requires apply mode") + started = monotonic() + report_progress = progress or _write_progress + report_progress("Checking gh authentication...") + client.ensure_authenticated() + repositories = client.list_repositories(org=org) + active_repositories = tuple( + repository for repository in repositories if not repository.archived + ) + _validate_requested_repositories(active_repositories, repository_names) + report_progress(f"Found {len(active_repositories)} active repositories.") + report_progress(f"Using checkout cache at {checkout_cache_directory}.") + + selected_repositories = _select_repositories(active_repositories, repository_names) + sync_failures = _sync_repositories( + client=client, + org=org, + repositories=selected_repositories, + checkout_cache_directory=checkout_cache_directory, + workers=sync_workers, + progress=report_progress, + ) + + synchronized = len(selected_repositories) - len(sync_failures) + skipped = sum( + repository.default_branch is None for repository in selected_repositories + ) + synchronized -= skipped + evaluations = compliant = drifted = not_applicable = evaluation_failures = 0 + pull_requests_created = pull_requests_updated = pull_requests_open = ( + pull_requests_recreated + ) = pull_requests_closed = 0 + outcomes: list[RepositoryOutcome] = [] + for policy in policies: + policy_outcomes = _run_policy_across_repositories( + client=client, + org=org, + policy=policy, + repositories=selected_repositories, + checkout_cache_directory=checkout_cache_directory, + apply=apply, + recreate=recreate, + allow_dirty_pr=allow_dirty_pr, + sync_failures=sync_failures, + workers=policy_workers, + progress=report_progress, + include_pull_request_status=include_pull_request_status, + ) + outcomes.extend(policy_outcomes) + for outcome in policy_outcomes: + if outcome.status in {"skipped", "sync-error"}: + continue + evaluations += 1 + if outcome.status == "error": + evaluation_failures += 1 + continue + if outcome.status in {"compliant", "pull-request-closed"}: + compliant += 1 + elif outcome.status == "not-applicable": + not_applicable += 1 + elif outcome.status in { + "changes-required", + "pull-request-created", + "pull-request-updated", + "pull-request-open", + "pull-request-recreated", + "pull-request-recreated-no-changes", + }: + drifted += 1 + if outcome.status == "pull-request-created": + pull_requests_created += 1 + elif outcome.status == "pull-request-updated": + pull_requests_updated += 1 + elif outcome.status == "pull-request-open": + pull_requests_open += 1 + elif outcome.status in { + "pull-request-recreated", + "pull-request-recreated-no-changes", + }: + pull_requests_recreated += 1 + if outcome.status == "pull-request-closed" or ( + outcome.status == "not-applicable" + and outcome.policy_pr_status == "closed" + ): + pull_requests_closed += 1 + return RunReport( + RunSummary( + repositories=len(selected_repositories), + synchronized=synchronized, + sync_failures=len(sync_failures), + skipped=skipped, + evaluations=evaluations, + compliant=compliant, + drifted=drifted, + not_applicable=not_applicable, + evaluation_failures=evaluation_failures, + pull_requests_created=pull_requests_created, + pull_requests_updated=pull_requests_updated, + pull_requests_open=pull_requests_open, + pull_requests_recreated=pull_requests_recreated, + pull_requests_closed=pull_requests_closed, + duration_seconds=monotonic() - started, + ), + tuple(outcomes), + ) + + +def _run_policy_across_repositories( + *, + client: RepositoryClient, + org: str, + policy: Policy, + repositories: tuple[Repository, ...], + checkout_cache_directory: Path, + apply: bool, + recreate: bool, + allow_dirty_pr: bool, + sync_failures: dict[str, str], + workers: int, + progress: Callable[[str], None], + include_pull_request_status: bool, +) -> tuple[RepositoryOutcome, ...]: + """Evaluate or apply one policy in independent repository checkouts concurrently.""" + + progress( + f"{policy.id}: processing {len(repositories)} repositories with {workers} worker(s)..." + ) + outcomes: list[RepositoryOutcome | None] = [None] * len(repositories) + futures = {} + with ThreadPoolExecutor( + max_workers=workers, thread_name_prefix=TOOL_SLUG + ) as executor: + for index, repository in enumerate(repositories): + if repository.default_branch is None: + outcomes[index] = RepositoryOutcome( + repository.name, policy.id, "unknown", "skipped" + ) + continue + if error := sync_failures.get(repository.name): + outcomes[index] = RepositoryOutcome( + repository.name, policy.id, "unknown", "sync-error", error=error + ) + continue + futures[ + executor.submit( + _run_policy_in_repository, + client=client, + org=org, + repository=repository, + policy=policy, + checkout=checkout_cache_directory / org / repository.name, + apply=apply, + recreate=recreate, + allow_dirty_pr=allow_dirty_pr, + include_pull_request_status=include_pull_request_status, + ) + ] = (index, repository) + for completed, future in enumerate(as_completed(futures), start=1): + index, repository = futures[future] + try: + outcome = future.result() + except (OSError, UnicodeError, RepoPolicySyncError) as exc: + outcome = RepositoryOutcome( + repository.name, + policy.id, + "unknown", + "error", + error=_policy_execution_error(exc), + ) + outcomes[index] = outcome + progress( + f" [{completed}/{len(futures)}] {repository.name}: {outcome.status}" + ) + return tuple(outcome for outcome in outcomes if outcome is not None) + + +def _run_policy_in_repository( + *, + client: RepositoryClient, + org: str, + repository: Repository, + policy: Policy, + checkout: Path, + apply: bool, + recreate: bool, + allow_dirty_pr: bool, + include_pull_request_status: bool, +) -> RepositoryOutcome: + client.restore_synced_default_branch(checkout=checkout) + if ( + repository.default_branch is None + ): # pragma: no cover - filtered before submission + raise RepoPolicySyncError(f"repository {repository.name} has no default branch") + return _run_repository( + client=client, + org=org, + repository=repository.name, + default_branch=repository.default_branch, + policy=policy, + checkout=checkout, + apply=apply, + recreate=recreate, + allow_dirty_pr=allow_dirty_pr, + include_pull_request_status=include_pull_request_status, + ) + + +def _run_repository( + *, + client: RepositoryClient, + org: str, + repository: str, + default_branch: str, + policy: Policy, + checkout: Path, + apply: bool, + recreate: bool = False, + allow_dirty_pr: bool = False, + include_pull_request_status: bool = False, +) -> RepositoryOutcome: + full_name = f"{org}/{repository}" + try: + evaluation = evaluate_policy(checkout, policy, organization=org) + except RepoPolicySyncError as exc: + if apply: + existing_pr = client.find_open_pull_request( + repository=full_name, + branches=policy_branches(policy), + policy_id=policy.id, + ) + if existing_pr is not None: + branch = existing_pr.branch or policy_branches(policy)[0] + try: + client.verify_policy_branch_head( + checkout=checkout, + branch=branch, + expected_head_oid=existing_pr.expected_head_oid, + ) + except RepoPolicySyncError: + pass + else: + client.update_pull_request( + repository=full_name, + pull_request=existing_pr, + policy=policy, + changes=(), + head_oid=existing_pr.expected_head_oid, + failure=str(exc), + ) + client.close_pull_request( + repository=full_name, pull_request=existing_pr + ) + raise + if not evaluation.applies: + if apply: + existing_pr = client.find_open_pull_request( + repository=full_name, + branches=policy_branches(policy), + policy_id=policy.id, + ) + if existing_pr is not None: + _close_owned_pull_request( + client=client, + repository=full_name, + policy=policy, + pull_request=existing_pr, + checkout=checkout, + ) + return RepositoryOutcome( + repository, + policy.id, + "no (live)", + "not-applicable", + pull_request_url=existing_pr.url, + policy_pr_status="closed", + ) + return RepositoryOutcome(repository, policy.id, "no (live)", "not-applicable") + if recreate: + return _recreate_repository( + client=client, + organization=org, + repository=repository, + full_name=full_name, + policy=policy, + checkout=checkout, + allow_dirty_pr=allow_dirty_pr, + ) + policy_pr_status = ( + _find_policy_pull_request_status( + client=client, + repository=full_name, + policy=policy, + ) + if include_pull_request_status + else None + ) + if not evaluation.changes: + existing_pr = ( + policy_pr_status.open + if policy_pr_status is not None + else ( + client.find_open_pull_request( + repository=full_name, + branches=policy_branches(policy), + policy_id=policy.id, + ) + if apply + else None + ) + ) + if apply and existing_pr is not None: + _close_owned_pull_request( + client=client, + repository=full_name, + policy=policy, + pull_request=existing_pr, + checkout=checkout, + ) + return RepositoryOutcome( + repository, + policy.id, + "yes (live)", + "pull-request-closed", + pull_request_url=existing_pr.url, + policy_pr_status="closed", + ) + return RepositoryOutcome( + repository, + policy.id, + "yes (live)", + "compliant", + pull_request_url=_policy_pr_url(policy_pr_status), + policy_pr_status=_policy_pr_label(policy_pr_status), + ) + if not apply: + return RepositoryOutcome( + repository, + policy.id, + "yes (live)", + "changes-required", + changes=evaluation.changes, + pull_request_url=_policy_pr_url(policy_pr_status), + policy_pr_status=_policy_pr_label(policy_pr_status), + ) + + branches = policy_branches(policy) + existing_pr = policy_pr_status.open if policy_pr_status is not None else None + if existing_pr is None: + existing_pr = client.find_open_pull_request( + repository=full_name, + branches=branches, + policy_id=policy.id, + ) + branch = (existing_pr.branch if existing_pr is not None else "") or branches[0] + if existing_pr is not None and existing_pr.expected_head_oid is None: + raise RepoPolicySyncError( + f"refusing to modify policy-owned pull request {existing_pr.url}: " + "it has no recognized branch-head marker" + ) + try: + if existing_pr is not None: + client.verify_policy_branch_head( + checkout=checkout, + branch=branch, + expected_head_oid=existing_pr.expected_head_oid, + ) + client.switch_to_policy_branch( + checkout=checkout, branch=branch, exists_remotely=existing_pr is not None + ) + applied = apply_policy(checkout, policy, organization=org) + head_oid = existing_pr.expected_head_oid if existing_pr is not None else "" + pre_commit_failure = None + if applied.changes: + commit_result = client.commit_and_push( + checkout=checkout, + branch=branch, + policy=policy, + changes=applied.changes, + allow_dirty_pr=allow_dirty_pr, + ) + head_oid, pre_commit_failure = _commit_result_parts(commit_result) + except RepoPolicySyncError as exc: + if existing_pr is not None: + try: + client.verify_policy_branch_head( + checkout=checkout, + branch=branch, + expected_head_oid=existing_pr.expected_head_oid, + ) + except RepoPolicySyncError: + pass + else: + client.update_pull_request( + repository=full_name, + pull_request=existing_pr, + policy=policy, + changes=evaluation.changes, + head_oid=existing_pr.expected_head_oid, + failure=str(exc), + ) + client.close_pull_request( + repository=full_name, pull_request=existing_pr + ) + raise + if not applied.changes: + if existing_pr is not None: + if existing_pr.mergeable == "CONFLICTING": + # The checkout currently contains the unchanged PR branch. Reset + # it to the freshly synchronized default branch before rebuilding + # the conflicted PR with the current policy. + client.restore_synced_default_branch(checkout=checkout) + return _recreate_existing_pull_request( + client=client, + organization=org, + repository=repository, + full_name=full_name, + policy=policy, + checkout=checkout, + existing_pr=existing_pr, + changes=evaluation.changes, + allow_dirty_pr=allow_dirty_pr, + ) + if _pull_request_body_changed( + existing_pr, + policy=policy, + changes=evaluation.changes, + head_oid=existing_pr.expected_head_oid, + ): + client.update_pull_request( + repository=full_name, + pull_request=existing_pr, + policy=policy, + changes=evaluation.changes, + head_oid=existing_pr.expected_head_oid, + ) + return RepositoryOutcome( + repository, + policy.id, + "yes (live)", + "pull-request-updated", + changes=evaluation.changes, + pull_request_url=existing_pr.url, + policy_pr_status="open", + ) + return RepositoryOutcome( + repository, + policy.id, + "yes (live)", + "pull-request-open", + changes=evaluation.changes, + pull_request_url=existing_pr.url, + policy_pr_status="open", + ) + return RepositoryOutcome( + repository, + policy.id, + "yes (live)", + "compliant", + pull_request_url=_policy_pr_url(policy_pr_status), + policy_pr_status=_policy_pr_label(policy_pr_status), + ) + if existing_pr is None: + pull_request = client.create_pull_request( + repository=full_name, + base=default_branch, + branch=branch, + policy=policy, + changes=applied.changes, + head_oid=head_oid, + draft=pre_commit_failure is not None, + ) + if pre_commit_failure is not None: + _comment_dirty_pull_request( + client=client, + repository=full_name, + pull_request=pull_request, + failure=pre_commit_failure, + ) + return RepositoryOutcome( + repository, + policy.id, + "yes (live)", + "pull-request-created", + changes=applied.changes, + pull_request_url=pull_request.url, + warnings=pull_request.warnings, + policy_pr_status="open", + ) + client.update_pull_request( + repository=full_name, + pull_request=existing_pr, + policy=policy, + changes=applied.changes, + head_oid=head_oid, + ) + if pre_commit_failure is not None: + _mark_dirty_pull_request( + client=client, + repository=full_name, + pull_request=existing_pr, + failure=pre_commit_failure, + ) + return RepositoryOutcome( + repository, + policy.id, + "yes (live)", + "pull-request-updated", + changes=applied.changes, + pull_request_url=existing_pr.url, + policy_pr_status="open", + ) + + +def _find_policy_pull_request_status( + *, client: RepositoryClient, repository: str, policy: Policy +) -> PolicyPullRequestStatus: + return client.find_policy_pull_request_status( + repository=repository, + branches=policy_branches(policy), + policy_id=policy.id, + ) + + +def _policy_execution_error(error: Exception) -> str: + """Turn local policy I/O failures into concise reportable diagnostics.""" + + return redact_sensitive_text(f"policy execution failed: {error}") + + +def _policy_pr_label(status: PolicyPullRequestStatus | None) -> str | None: + if status is None: + return None + if status.open is not None: + return "open" + if status.merged is not None: + return "merged" + return "none" + + +def _policy_pr_url(status: PolicyPullRequestStatus | None) -> str | None: + if status is None: + return None + pull_request = status.open or status.merged + return pull_request.url if pull_request is not None else None + + +def _close_owned_pull_request( + *, + client: RepositoryClient, + repository: str, + policy: Policy, + pull_request: object, + checkout: Path, +) -> None: + """Close an owned PR only after confirming its branch is still tool-owned.""" + + expected_head_oid = pull_request.expected_head_oid + if expected_head_oid is None: + raise RepoPolicySyncError( + f"refusing to close policy-owned pull request {pull_request.url}: " + "it has no recognized branch-head marker" + ) + branch = pull_request.branch or policy_branches(policy)[0] + client.verify_policy_branch_head( + checkout=checkout, + branch=branch, + expected_head_oid=expected_head_oid, + ) + client.close_pull_request(repository=repository, pull_request=pull_request) + + +def _pull_request_body_changed( + pull_request: object, + *, + policy: Policy, + changes: tuple[Change, ...], + head_oid: 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) + + +def _commit_result_parts(result: CommitResult) -> tuple[str, str | None]: + return result.head_oid, result.pre_commit_failure + + +def _mark_dirty_pull_request( + *, client: RepositoryClient, repository: str, pull_request: object, failure: str +) -> None: + client.mark_pull_request_draft(repository=repository, pull_request=pull_request) + _comment_dirty_pull_request( + client=client, repository=repository, pull_request=pull_request, failure=failure + ) + + +def _comment_dirty_pull_request( + *, client: RepositoryClient, repository: str, pull_request: object, failure: str +) -> None: + client.comment_on_pull_request( + repository=repository, pull_request=pull_request, failure=failure + ) + + +def _recreate_repository( + *, + client: RepositoryClient, + organization: str, + repository: str, + full_name: str, + policy: Policy, + checkout: Path, + allow_dirty_pr: bool = False, +) -> RepositoryOutcome: + """Rebuild an existing policy branch from the freshly synced default branch.""" + + branches = policy_branches(policy) + existing_pr = client.find_open_pull_request( + repository=full_name, + branches=branches, + policy_id=policy.id, + ) + if existing_pr is None: + raise RepoPolicySyncError( + f"cannot recreate {policy.id} for {repository}: no open policy-owned pull request" + ) + return _recreate_existing_pull_request( + client=client, + organization=organization, + repository=repository, + full_name=full_name, + policy=policy, + checkout=checkout, + existing_pr=existing_pr, + allow_dirty_pr=allow_dirty_pr, + ) + + +def _recreate_existing_pull_request( + *, + client: RepositoryClient, + organization: str, + repository: str, + full_name: str, + policy: Policy, + checkout: Path, + existing_pr: object, + changes: tuple[Change, ...] | None = None, + allow_dirty_pr: bool = False, +) -> RepositoryOutcome: + """Rebuild one known policy PR from the freshly synchronized default branch.""" + + branches = policy_branches(policy) + branch = existing_pr.branch or branches[0] + if existing_pr.expected_head_oid is None: + raise RepoPolicySyncError( + f"refusing to recreate policy-owned pull request {existing_pr.url}: " + "it has no recognized branch-head marker" + ) + client.verify_policy_branch_head( + checkout=checkout, + branch=branch, + expected_head_oid=existing_pr.expected_head_oid, + ) + client.recreate_policy_branch(checkout=checkout, branch=branch) + applied = apply_policy( + checkout, policy, force_after_apply=True, organization=organization + ) + if not client.has_changes(checkout=checkout, changes=applied.changes): + body_changes = applied.changes if changes is None else changes + if _pull_request_body_changed( + existing_pr, + policy=policy, + changes=body_changes, + head_oid=existing_pr.expected_head_oid, + ): + client.update_pull_request( + repository=full_name, + pull_request=existing_pr, + policy=policy, + changes=body_changes, + head_oid=existing_pr.expected_head_oid, + ) + return RepositoryOutcome( + repository, + policy.id, + "yes (live)", + "pull-request-recreated-no-changes", + pull_request_url=existing_pr.url, + policy_pr_status="open", + ) + commit_result = client.commit_and_force_push( + checkout=checkout, + branch=branch, + expected_head_oid=existing_pr.expected_head_oid, + policy=policy, + changes=applied.changes, + allow_dirty_pr=allow_dirty_pr, + ) + head_oid, pre_commit_failure = _commit_result_parts(commit_result) + client.update_pull_request( + repository=full_name, + pull_request=existing_pr, + policy=policy, + changes=applied.changes, + head_oid=head_oid, + ) + if pre_commit_failure is not None: + _mark_dirty_pull_request( + client=client, + repository=full_name, + pull_request=existing_pr, + failure=pre_commit_failure, + ) + return RepositoryOutcome( + repository, + policy.id, + "yes (live)", + "pull-request-recreated", + changes=applied.changes, + pull_request_url=existing_pr.url, + policy_pr_status="open", + ) + + +def _sync_repositories( + *, + client: RepositoryClient, + org: str, + repositories: tuple[Repository, ...], + checkout_cache_directory: Path, + workers: int, + progress: Callable[[str], None], +) -> dict[str, str]: + """Refresh each checkout concurrently before any policy can modify one.""" + + repositories_with_branches = tuple( + repository + for repository in repositories + if repository.default_branch is not None + ) + if not repositories_with_branches: + return {} + progress( + f"Synchronizing {len(repositories_with_branches)} checkout(s) with {workers} worker(s)..." + ) + failures: dict[str, str] = {} + with ThreadPoolExecutor( + max_workers=workers, thread_name_prefix=TOOL_SLUG + ) as executor: + futures = { + executor.submit( + client.sync_default_branch, + repository=f"{org}/{repository.name}", + branch=repository.default_branch, + destination=checkout_cache_directory / org / repository.name, + ): repository + for repository in repositories_with_branches + } + for index, future in enumerate(as_completed(futures), start=1): + repository = futures[future] + try: + future.result() + except (RepoPolicySyncError, OSError) as exc: + failures[repository.name] = redact_sensitive_text( + str(exc) or exc.__class__.__name__ + ) + status = "failed" + else: + status = "done" + progress( + f" [{index}/{len(repositories_with_branches)}] {repository.name}: {status}" + ) + return failures + + +def _validate_requested_repositories( + repositories: tuple[Repository, ...], names: tuple[str, ...] +) -> None: + available = {repository.name for repository in repositories} + missing = sorted(set(names) - available) + if missing: + raise RepoPolicySyncError( + f"repository filter not found in organization: {', '.join(missing)}" + ) + + +def _select_repositories( + repositories: tuple[Repository, ...], names: tuple[str, ...] +) -> tuple[Repository, ...]: + requested = set(names) + return tuple( + repository + for repository in repositories + if not requested or repository.name in requested + ) + + +def _write_progress(message: str) -> None: + print(message, file=sys.stderr, flush=True) diff --git a/repo_policy_sync/samples.py b/repo_policy_sync/samples.py new file mode 100644 index 0000000..31e7514 --- /dev/null +++ b/repo_policy_sync/samples.py @@ -0,0 +1,201 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Read-only collection of representative policy input samples.""" + +from __future__ import annotations + +import json +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from .engine import matches_policy_conditions, policy_sample_paths +from .errors import RepoPolicySyncError, redact_sensitive_text +from .models import Policy, Repository +from .runner import ( + DEFAULT_SYNC_WORKERS, + RepositoryClient, + _select_repositories, + _sync_repositories, + _validate_requested_repositories, +) + + +@dataclass(frozen=True) +class SampleFile: + """One repository file copied into a collected sample case.""" + + path: Path + size: int + + +@dataclass(frozen=True) +class SampleCase: + """The files selected for one repository and policy pair.""" + + policy_id: str + repository: str + files: tuple[SampleFile, ...] + + +@dataclass(frozen=True) +class SampleCollectionReport: + """Collected cases and checkout failures.""" + + output: Path + cases: tuple[SampleCase, ...] + sync_failures: tuple[tuple[str, str], ...] = () + + +def collect_samples( + *, + client: RepositoryClient, + org: str, + policies: tuple[Policy, ...], + repository_names: tuple[str, ...], + checkout_cache_directory: Path, + output_directory: Path, + sync_workers: int = DEFAULT_SYNC_WORKERS, + progress: Callable[[str], None] | None = None, +) -> SampleCollectionReport: + """Collect policy-matching workflow files without changing repositories.""" + + if sync_workers < 1: + raise RepoPolicySyncError("sync worker count must be at least 1") + _prepare_output_directory(output_directory) + report_progress = progress or _write_progress + report_progress("Checking gh authentication...") + client.ensure_authenticated() + repositories = tuple( + repository + for repository in client.list_repositories(org=org) + if not repository.archived + ) + _validate_requested_repositories(repositories, repository_names) + selected = _select_repositories(repositories, repository_names) + report_progress(f"Found {len(selected)} active repositories.") + report_progress(f"Using checkout cache at {checkout_cache_directory}.") + sync_failures = _sync_repositories( + client=client, + org=org, + repositories=selected, + checkout_cache_directory=checkout_cache_directory, + workers=sync_workers, + progress=report_progress, + ) + + cases: list[SampleCase] = [] + for policy in policies: + for repository in selected: + if repository.default_branch is None or repository.name in sync_failures: + continue + checkout = checkout_cache_directory / org / repository.name + try: + if not matches_policy_conditions(checkout, policy): + continue + paths = policy_sample_paths(checkout, policy) + case = _copy_case( + output_directory, + policy, + repository, + checkout, + paths, + ) + except (OSError, UnicodeError, RepoPolicySyncError) as exc: + raise RepoPolicySyncError( + f"could not collect {policy.id} from {repository.name}: " + f"{redact_sensitive_text(str(exc))}" + ) from exc + cases.append(case) + report_progress( + f" {policy.id}/{repository.name}: {len(case.files)} file(s)" + ) + + report = SampleCollectionReport( + output=output_directory, + cases=tuple(cases), + sync_failures=tuple(sorted(sync_failures.items())), + ) + _write_inventory(report) + return report + + +def _prepare_output_directory(output: Path) -> None: + if output.exists(): + if not output.is_dir(): + raise RepoPolicySyncError(f"sample output is not a directory: {output}") + if any(output.iterdir()): + raise RepoPolicySyncError(f"sample output directory is not empty: {output}") + else: + output.mkdir(parents=True) + + +def _copy_case( + output: Path, + policy: Policy, + repository: Repository, + checkout: Path, + paths: tuple[Path, ...], +) -> SampleCase: + case_root = output / policy.id / repository.name / "before" + files: list[SampleFile] = [] + for path in paths: + relative = path.relative_to(checkout) + destination = case_root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(path, destination) + files.append(SampleFile(relative, path.stat().st_size)) + return SampleCase(policy.id, repository.name, tuple(files)) + + +def _write_inventory(report: SampleCollectionReport) -> None: + inventory = { + "schema_version": 1, + "cases": [ + { + "policy_id": case.policy_id, + "repository": case.repository, + "files": [ + {"path": str(sample.path), "size": sample.size} + for sample in case.files + ], + } + for case in report.cases + ], + "sync_failures": [ + {"repository": repository, "error": error} + for repository, error in report.sync_failures + ], + } + (report.output / "inventory.json").write_text( + json.dumps(inventory, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + +def render_sample_collection(report: SampleCollectionReport) -> str: + """Render a concise summary for the command line.""" + + lines = [ + "📦 Workflow samples", + f"Collected {len(report.cases)} case(s) in {report.output}.", + ] + if report.sync_failures: + lines.append(f"Checkout failures: {len(report.sync_failures)}.") + return "\n".join(lines) + + +def _write_progress(message: str) -> None: + print(message, file=sys.stderr, flush=True) diff --git a/repo_policy_sync/templates/pull_request.md b/repo_policy_sync/templates/pull_request.md new file mode 100644 index 0000000..74c1146 --- /dev/null +++ b/repo_policy_sync/templates/pull_request.md @@ -0,0 +1,40 @@ + + +## Policy + +**`{{ policy_id }}`** + +{{ policy_description }} + +## Why this repository? + +{{ policy_trigger }} + +## Changes + +{{ changes }} + +{{ failure_section }} + +--- + +This pull request is managed by SCORE Repository Policy Sync and may be updated by a later policy run. +Please report any issues to [#score-infrastructure](https://sdvworkinggroup.slack.com/archives/C0894QGRZDM). + +> [!NOTE] +> This pull request is generated automatically. Review the proposed changes +> before merging. + +{{ policy_marker }} +{{ policy_head_marker }} diff --git a/repo_policy_sync/tests/test_cache.py b/repo_policy_sync/tests/test_cache.py new file mode 100644 index 0000000..f64978c --- /dev/null +++ b/repo_policy_sync/tests/test_cache.py @@ -0,0 +1,25 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from pathlib import Path + +from repo_policy_sync.cache import default_checkout_cache_directory + + +def test_default_checkout_cache_uses_generic_xdg_location( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + + assert default_checkout_cache_directory() == tmp_path / "repo-cache" diff --git a/repo_policy_sync/tests/test_cli.py b/repo_policy_sync/tests/test_cli.py new file mode 100644 index 0000000..dde2950 --- /dev/null +++ b/repo_policy_sync/tests/test_cli.py @@ -0,0 +1,527 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from pathlib import Path + +import pytest + +from repo_policy_sync import cli +from repo_policy_sync.policy import BUNDLED_POLICY_DIRECTORY +from repo_policy_sync.runner import RunReport, RunSummary +from repo_policy_sync.samples import SampleCollectionReport + + +def _empty_report() -> RunReport: + return RunReport( + summary=RunSummary( + repositories=0, + synchronized=0, + sync_failures=0, + skipped=0, + evaluations=0, + compliant=0, + drifted=0, + not_applicable=0, + evaluation_failures=0, + pull_requests_created=0, + pull_requests_updated=0, + pull_requests_open=0, + pull_requests_recreated=0, + ), + outcomes=(), + ) + + +@pytest.mark.parametrize( + "argv, message", + [ + (("plan", "--org", "eclipse-score", "--recreate"), "unrecognized arguments"), + ( + ("apply", "--org", "eclipse-score", "--recreate", "--policy", "example"), + "exactly one --repo", + ), + ( + ("apply", "--org", "eclipse-score", "--recreate", "--repo", "example"), + "exactly one --policy", + ), + ], +) +def test_recreate_requires_an_explicit_single_target(argv, message, capsys) -> None: + with pytest.raises(SystemExit) as exit_code: + cli.main(argv) + + assert exit_code.value.code == 2 + assert message in capsys.readouterr().err + + +def test_default_output_is_a_table(monkeypatch, capsys) -> None: + report = RunReport( + summary=RunSummary( + repositories=1, + synchronized=1, + sync_failures=0, + skipped=0, + evaluations=1, + compliant=0, + drifted=1, + not_applicable=0, + evaluation_failures=0, + pull_requests_created=0, + pull_requests_updated=0, + pull_requests_open=0, + pull_requests_recreated=0, + ), + outcomes=(), + ) + monkeypatch.setattr(cli, "load_policies", lambda _: ()) + monkeypatch.setattr(cli, "run_policies", lambda **_: report) + + assert ( + cli.main( + ( + "plan", + "--org", + "eclipse-score", + "--policy-dir", + "repo_policy_sync/policies", + "--quiet", + ) + ) + == 1 + ) + + captured = capsys.readouterr() + assert captured.err == "" + assert "📋 Policy evaluations" in captured.out + + +def test_all_reports_can_be_written_from_one_run( + monkeypatch, tmp_path: Path, capsys +) -> None: + report = _empty_report() + observed = {} + monkeypatch.setattr(cli, "load_policies", lambda _: ()) + monkeypatch.setattr( + cli, + "run_policies", + lambda **kwargs: observed.update(kwargs) or report, + ) + json_path = tmp_path / "report.json" + markdown_path = tmp_path / "report.md" + + assert ( + cli.main( + ( + "plan", + "--org", + "eclipse-score", + "--policy-dir", + "repo_policy_sync/policies", + "--json-output", + str(json_path), + "--markdown-output", + str(markdown_path), + "--quiet", + ) + ) + == 0 + ) + + assert '"schema_version": 2' in json_path.read_text(encoding="utf-8") + assert "# Repository policy compliance" in markdown_path.read_text(encoding="utf-8") + assert "📋 Policy evaluations" in capsys.readouterr().out + assert observed["include_pull_request_status"] is True + + +def test_json_report_requests_pull_request_status( + monkeypatch, tmp_path: Path, capsys +) -> None: + report = _empty_report() + observed = {} + monkeypatch.setattr(cli, "load_policies", lambda _: ()) + monkeypatch.setattr( + cli, + "run_policies", + lambda **kwargs: observed.update(kwargs) or report, + ) + json_path = tmp_path / "report.json" + + assert ( + cli.main( + ( + "plan", + "--org", + "eclipse-score", + "--policy-dir", + "repo_policy_sync/policies", + "--json-output", + str(json_path), + "--quiet", + ) + ) + == 0 + ) + + assert observed["include_pull_request_status"] is True + capsys.readouterr() + + +def test_collect_samples_is_a_read_only_command( + monkeypatch, tmp_path: Path, capsys +) -> None: + observed = {} + output = tmp_path / "samples" + monkeypatch.setattr(cli, "load_policies", lambda _: ()) + monkeypatch.setattr( + cli, + "collect_samples", + lambda **kwargs: ( + observed.update(kwargs) or SampleCollectionReport(output=output, cases=()) + ), + ) + + assert ( + cli.main( + ( + "collect-samples", + "--org", + "eclipse-score", + "--output", + str(output), + "--quiet", + ) + ) + == 0 + ) + assert observed["output_directory"] == output + assert observed["repository_names"] == () + assert "📦 Workflow samples" in capsys.readouterr().out + + +def test_recreate_selects_only_the_requested_bundled_policy( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.chdir(tmp_path) + report = _empty_report() + loaded_paths = [] + observed = {} + monkeypatch.setattr( + cli, "load_policies", lambda paths: loaded_paths.extend(paths) or () + ) + monkeypatch.setattr( + cli, + "run_policies", + lambda **kwargs: observed.update(kwargs) or report, + ) + + assert ( + cli.main( + ( + "apply", + "--org", + "eclipse-score", + "--repo", + "reference_integration", + "--policy", + "minimum-bazel-version", + "--recreate", + "--quiet", + ) + ) + == 0 + ) + + assert loaded_paths == [ + BUNDLED_POLICY_DIRECTORY / "minimum-bazel-version" / "policy.yml" + ] + assert observed["recreate"] is True + + +def test_recreate_does_not_load_unrelated_local_policies( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.chdir(tmp_path) + policy_directory = tmp_path / "policies" + target = policy_directory / "target" / "policy.yml" + target.parent.mkdir(parents=True) + target.write_text("not loaded here\n") + unrelated = policy_directory / "unrelated" / "policy.yml" + unrelated.parent.mkdir() + unrelated.write_text("this is invalid policy YAML: [\n") + report = _empty_report() + loaded_paths = [] + monkeypatch.setattr( + cli, "load_policies", lambda paths: loaded_paths.extend(paths) or () + ) + monkeypatch.setattr(cli, "run_policies", lambda **_: report) + + assert ( + cli.main( + ( + "apply", + "--org", + "eclipse-score", + "--repo", + "reference_integration", + "--policy-dir", + str(policy_directory), + "--policy", + "target", + "--recreate", + "--quiet", + ) + ) + == 0 + ) + + assert loaded_paths == [target] + + +def test_bundled_policies_do_not_require_a_local_policy_directory( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.chdir(tmp_path) + report = _empty_report() + loaded_paths = [] + monkeypatch.setattr( + cli, "load_policies", lambda paths: loaded_paths.extend(paths) or () + ) + monkeypatch.setattr(cli, "run_policies", lambda **_: report) + + assert ( + cli.main( + ( + "plan", + "--org", + "etas-eng", + "--repo", + "vsps_product", + "--quiet", + ) + ) + == 0 + ) + assert [path.parent.name for path in loaded_paths] == [ + path.name + for path in sorted( + BUNDLED_POLICY_DIRECTORY.iterdir(), key=lambda path: str(path) + ) + if path.is_dir() + ] + + loaded_paths.clear() + assert ( + cli.main( + ( + "plan", + "--org", + "etas-eng", + "--repo", + "vsps_product", + "--exclude-bundled-policy", + "minimum-bazel-version", + "--quiet", + ) + ) + == 0 + ) + assert all(path.parent.name != "minimum-bazel-version" for path in loaded_paths) + + +def test_bundled_policy_selected_from_bundled_directory_is_not_loaded_twice( + monkeypatch, +) -> None: + report = _empty_report() + loaded_paths = [] + monkeypatch.setattr( + cli, "load_policies", lambda paths: loaded_paths.extend(paths) or () + ) + monkeypatch.setattr(cli, "run_policies", lambda **_: report) + + assert ( + cli.main( + ( + "plan", + "--org", + "eclipse-score", + "--repo", + "reference_integration", + "--policy-dir", + "repo_policy_sync/policies", + "--policy", + "minimum-bazel-version", + "--quiet", + ) + ) + == 0 + ) + + resolved_paths = [path.resolve() for path in loaded_paths] + assert len(resolved_paths) == len(set(resolved_paths)) + assert any(path.parent.name == "minimum-bazel-version" for path in loaded_paths) + + +def test_config_values_are_overridden_by_explicit_cli_values( + monkeypatch, tmp_path: Path +) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text( + """[score-repo-policy-sync] +org = "config-org" +repos = ["config-repo"] +policy_dirs = [] +exclude_bundled_policies = ["minimum-bazel-version"] +recreate = false +allow_dirty_pr = true +quiet = true +cache_dir = "config-cache" +sync_workers = 2 +policy_workers = 3 +""", + encoding="utf-8", + ) + loaded_paths = [] + observed = {} + monkeypatch.setattr( + cli, "load_policies", lambda paths: loaded_paths.extend(paths) or () + ) + monkeypatch.setattr( + cli, + "run_policies", + lambda **kwargs: observed.update(kwargs) or _empty_report(), + ) + + assert ( + cli.main( + ( + "apply", + "--config", + str(config_path), + "--org", + "cli-org", + "--repo", + "cli-repo", + "--no-allow-dirty-pr", + "--exclude-bundled-policy", + "score-devcontainer-dockerfile-migration", + "--no-quiet", + "--cache-dir", + "cli-cache", + "--sync-workers", + "7", + "--policy-workers", + "8", + ) + ) + == 0 + ) + assert observed["org"] == "cli-org" + assert observed["repository_names"] == ("cli-repo",) + assert observed["apply"] is True + assert observed["allow_dirty_pr"] is False + assert observed["sync_workers"] == 7 + assert observed["policy_workers"] == 8 + assert observed["checkout_cache_directory"] == Path("cli-cache") + assert observed["include_pull_request_status"] is False + assert all( + path.parent.name != "score-devcontainer-dockerfile-migration" + for path in loaded_paths + ) + assert any(path.parent.name == "minimum-bazel-version" for path in loaded_paths) + + +def test_configurable_defaults_are_left_unset_for_config_merging() -> None: + args = cli.create_parser().parse_args(("plan", "--org", "eclipse-score")) + + assert args.sync_workers is None + assert args.policy_workers is None + + +def test_policy_directory_defaults_to_current_working_directory() -> None: + args = cli.create_parser().parse_args(("plan", "--org", "eclipse-score")) + + assert args.policy_dir is None + assert args.config is None + assert args.exclude_bundled_policy is None + + +def test_dirty_pull_requests_can_be_enabled() -> None: + args = cli.create_parser().parse_args( + ("apply", "--org", "eclipse-score", "--allow-dirty-pr") + ) + + assert args.allow_dirty_pr is True + + +def test_policy_directory_can_be_repeated() -> None: + args = cli.create_parser().parse_args( + ( + "plan", + "--org", + "eclipse-score", + "--policy-dir", + "policies", + "--policy-dir", + "shared-policies", + ) + ) + + assert args.policy_dir == [Path("policies"), Path("shared-policies")] + + +def test_removed_policy_directory_alias_is_rejected() -> None: + with pytest.raises(SystemExit) as exit_code: + cli.create_parser().parse_args( + ("plan", "--org", "eclipse-score", "--policy-directory", "policies") + ) + + assert exit_code.value.code == 2 + + +def test_bundled_policies_can_be_excluded_separately() -> None: + args = cli.create_parser().parse_args( + ( + "plan", + "--org", + "eclipse-score", + "--exclude-bundled-policy", + "minimum-bazel-version", + ) + ) + + assert args.exclude_bundled_policy == ["minimum-bazel-version"] + + +def test_help_groups_options_by_frequency(capsys) -> None: + with pytest.raises(SystemExit) as exit_code: + cli.create_parser().parse_args(("--help",)) + + assert exit_code.value.code == 0 + help_text = capsys.readouterr().out + assert "plan" in help_text + assert "apply" in help_text + assert "collect-samples" in help_text + + with pytest.raises(SystemExit) as exit_code: + cli.create_parser().parse_args(("apply", "--help")) + + assert exit_code.value.code == 0 + help_text = capsys.readouterr().out + assert ( + help_text.index("Typical:") + < help_text.index("Rare:") + < help_text.index("Debugging only:") + ) + _, debugging_help = help_text.split("Debugging only:", 1) + assert "--recreate" in help_text + assert "--cache-dir" in debugging_help diff --git a/repo_policy_sync/tests/test_config.py b/repo_policy_sync/tests/test_config.py new file mode 100644 index 0000000..b44353c --- /dev/null +++ b/repo_policy_sync/tests/test_config.py @@ -0,0 +1,95 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from pathlib import Path + +import pytest + +from repo_policy_sync.config import load_config +from repo_policy_sync.errors import RepoPolicySyncError + + +def test_load_config_resolves_policy_directories_relative_to_config( + tmp_path: Path, +) -> None: + config_path = tmp_path / "score-repo-policy-sync.toml" + config_path.write_text( + """[score-repo-policy-sync] +org = "eclipse-score" +policies = ["minimum-bazel-version"] +repos = ["reference_integration"] +policy_dirs = ["policies", "shared"] +exclude_bundled_policies = ["minimum-bazel-version"] +recreate = true +allow_dirty_pr = true +quiet = true +cache_dir = ".cache/repo-sync" +sync_workers = 2 +policy_workers = 3 +""", + encoding="utf-8", + ) + + config = load_config(config_path) + + assert config.org == "eclipse-score" + assert config.policies == ("minimum-bazel-version",) + assert config.repositories == ("reference_integration",) + assert config.policy_directories == (tmp_path / "policies", tmp_path / "shared") + assert config.exclude_bundled_policies == ("minimum-bazel-version",) + assert config.recreate is True + assert config.allow_dirty_pr is True + assert config.quiet is True + assert config.cache_directory == tmp_path / ".cache/repo-sync" + assert config.sync_workers == 2 + assert config.policy_workers == 3 + + +def test_load_config_without_a_default_file_is_empty( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + + config = load_config() + + assert config.policy_directories is None + assert config.exclude_bundled_policies == () + + +def test_load_config_rejects_unknown_fields(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text( + """[score-repo-policy-sync] +unexpected = true +""", + encoding="utf-8", + ) + + with pytest.raises(RepoPolicySyncError, match="unexpected fields"): + load_config(config_path) + + +def test_load_config_rejects_unknown_sections(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text("[wrong-section]\nvalue = true\n", encoding="utf-8") + + with pytest.raises(RepoPolicySyncError, match="unexpected sections"): + load_config(config_path) + + +def test_load_config_rejects_non_utf8_input(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + config_path.write_bytes(b"[score-repo-policy-sync]\norg = '\xff'\n") + + with pytest.raises(RepoPolicySyncError, match="decode configuration.*UTF-8"): + load_config(config_path) diff --git a/repo_policy_sync/tests/test_engine.py b/repo_policy_sync/tests/test_engine.py new file mode 100644 index 0000000..721179e --- /dev/null +++ b/repo_policy_sync/tests/test_engine.py @@ -0,0 +1,1418 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from pathlib import Path +import subprocess + +import pytest + +from repo_policy_sync.engine import apply_policy, evaluate_policy +from repo_policy_sync.errors import RepoPolicySyncError +from repo_policy_sync.policy import BUNDLED_POLICY_DIRECTORY, load_policy +from repo_policy_sync.models import ( + AfterApplyCommand, + BazelDependencyUpdate, + BazelDependencyCondition, + BazelCondition, + Change, + EnsureLine, + EnsureBazelDependency, + EnsureMinimumVersion, + EnsureNoSuchFile, + FileContainsCondition, + Policy, + ReplaceRegex, + SynchronizeFile, + SynchronizeBazelDependencies, + SynchronizeDevcontainerVersion, +) + + +def _policy() -> Policy: + return Policy( + id="score-docs-as-code.gitignore-and-cleanup", + title="Update docs files", + description=None, + bazel_condition=BazelCondition(("score_docs_as_code",)), + ensure=( + EnsureLine(Path(".gitignore"), "_build", ("/_build",)), + EnsureLine(Path(".gitignore"), "ubproject.toml", ("/docs/ubproject.toml",)), + EnsureNoSuchFile(Path("docs/ubproject.toml")), + ), + ) + + +def test_apply_policy_replaces_legacy_lines_and_removes_file(tmp_path: Path) -> None: + (tmp_path / "MODULE.bazel").write_text( + 'module(name = "example")\nbazel_dep(name = "score_docs_as_code", version = "1.0")\n' + ) + (tmp_path / ".gitignore").write_text( + "/keep\n/_build\n/docs/ubproject.toml\n/_build\n" + ) + (tmp_path / "docs").mkdir() + (tmp_path / "docs/ubproject.toml").write_text("legacy\n") + + evaluation = apply_policy(tmp_path, _policy()) + + assert len(evaluation.changes) == 3 + assert (tmp_path / ".gitignore").read_text() == "/keep\n_build\nubproject.toml\n" + assert not (tmp_path / "docs/ubproject.toml").exists() + assert apply_policy(tmp_path, _policy()).changes == () + + +def test_policy_does_not_apply_without_direct_dependency(tmp_path: Path) -> None: + (tmp_path / "MODULE.bazel").write_text( + 'bazel_dep(name = "other", version = "1.0")\n' + ) + evaluation = evaluate_policy(tmp_path, _policy()) + assert not evaluation.applies + assert evaluation.changes == () + + +def test_bazel_condition_ignores_commented_dependency(tmp_path: Path) -> None: + (tmp_path / "MODULE.bazel").write_text( + '# bazel_dep(name = "score_docs_as_code", version = "1.0.0")\n' + ) + + evaluation = evaluate_policy(tmp_path, _policy()) + + assert evaluation.applies is False + + +@pytest.mark.parametrize( + "declaration", + ( + 'bazel_dep(name = "score_docs_as_code", version = "1.0")\n', + 'bazel_dep(name = "score_docs_as_code", version = "1.0.0-rc1")\n', + 'bazel_dep(name = "score_docs_as_code")\n', + ), +) +def test_bazel_condition_rejects_uncomparable_configured_versions( + tmp_path: Path, declaration: str +) -> None: + (tmp_path / "MODULE.bazel").write_text(declaration) + policy = Policy( + "example", + "Example", + None, + BazelCondition( + (), + any_direct_module_conditions=( + BazelDependencyCondition("score_docs_as_code", ">=", (1, 0, 0)), + ), + ), + (), + ) + + with pytest.raises(RepoPolicySyncError, match="numeric major.minor.patch"): + evaluate_policy(tmp_path, policy) + + +def test_ensure_bazel_dependency_ignores_commented_dependency(tmp_path: Path) -> None: + (tmp_path / "Dockerfile").write_text( + "FROM ghcr.io/eclipse-score/devcontainer:v1.9.0\n" + ) + module = tmp_path / "MODULE.bazel" + module.write_text('# bazel_dep(name = "score_devcontainer", version = "1.0.0")\n') + policy = Policy( + "example", + "Example", + None, + None, + ( + EnsureBazelDependency( + Path("Dockerfile"), + Path("MODULE.bazel"), + "ghcr.io/eclipse-score/devcontainer", + "score_devcontainer", + ), + ), + ) + + apply_policy(tmp_path, policy) + + assert module.read_text().count('name = "score_devcontainer"') == 2 + + +def test_synchronize_bazel_dependencies_ignores_commented_dependency( + tmp_path: Path, +) -> None: + module = tmp_path / "MODULE.bazel" + module.write_text( + '# bazel_dep(name = "optional_module", version = "1.0.0")\n' + 'bazel_dep(name = "score_platform", version = "0.6.3")\n' + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeBazelDependencies( + Path("MODULE.bazel"), + ( + BazelDependencyUpdate("optional_module", "2.0.0", optional=True), + BazelDependencyUpdate("score_platform", "0.7.0"), + ), + ), + ), + ) + + apply_policy(tmp_path, policy) + + assert 'version = "0.7.0"' in module.read_text() + assert 'version = "1.0.0"' in module.read_text() + + +def test_ensure_line_replaces_complete_line_glob_matches(tmp_path: Path) -> None: + (tmp_path / "MODULE.bazel").write_text('bazel_dep(name = "score_docs_as_code")\n') + (tmp_path / ".gitignore").write_text("prefix_build_suffix\n_build\n") + policy = Policy( + "example", + "Example", + None, + BazelCondition(("score_docs_as_code",)), + (EnsureLine(Path(".gitignore"), "_build", (), ("*_build*",)),), + ) + + apply_policy(tmp_path, policy) + + assert (tmp_path / ".gitignore").read_text() == "_build\n" + assert apply_policy(tmp_path, policy).changes == () + + +@pytest.mark.parametrize( + ("operation", "path"), + [ + ( + EnsureLine(Path("target"), "required", ()), + Path("target"), + ), + ( + EnsureMinimumVersion(Path("target"), "8.6.0"), + Path("target"), + ), + ( + ReplaceRegex(Path("target"), "legacy", "current"), + Path("target"), + ), + ], +) +def test_path_operations_reject_directory_targets( + tmp_path: Path, operation, path: Path +) -> None: + (tmp_path / path).mkdir() + + with pytest.raises(RepoPolicySyncError, match="must be a file"): + apply_policy(tmp_path, Policy("example", "Example", None, None, (operation,))) + + +def test_ensure_no_such_file_refuses_to_remove_a_directory(tmp_path: Path) -> None: + (tmp_path / "MODULE.bazel").write_text('bazel_dep(name = "score_docs_as_code")\n') + (tmp_path / "docs/ubproject.toml").mkdir(parents=True) + + with pytest.raises(RepoPolicySyncError, match="refusing to remove directory"): + apply_policy(tmp_path, _policy()) + + +def test_ensure_no_such_file_refuses_a_directory_during_evaluation( + tmp_path: Path, +) -> None: + (tmp_path / "MODULE.bazel").write_text('bazel_dep(name = "score_docs_as_code")\n') + (tmp_path / "docs/ubproject.toml").mkdir(parents=True) + + with pytest.raises(RepoPolicySyncError, match="refusing to remove directory"): + evaluate_policy(tmp_path, _policy()) + + +def test_replace_regex_applies_and_is_idempotent(tmp_path: Path) -> None: + (tmp_path / "example.txt").write_text("legacy\nunchanged\n") + policy = Policy( + "example", + "Example", + None, + None, + ( + ReplaceRegex( + Path("example.txt"), + "legacy", + "current", + ), + ), + ) + + evaluation = apply_policy(tmp_path, policy) + + assert evaluation.changes == (Change(Path("example.txt"), "replace matching text"),) + assert (tmp_path / "example.txt").read_text() == "current\nunchanged\n" + assert apply_policy(tmp_path, policy).changes == () + + +def test_ensure_minimum_version_upgrades_only_older_versions(tmp_path: Path) -> None: + version_file = tmp_path / ".bazelversion" + policy = Policy( + "example", + "Example", + None, + None, + (EnsureMinimumVersion(Path(".bazelversion"), "8.6.0"),), + ) + + version_file.write_text("8.5.2\n") + assert apply_policy(tmp_path, policy).changes == ( + Change(Path(".bazelversion"), "upgrade from '8.5.2' to '8.6.0'"), + ) + assert version_file.read_text() == "8.6.0\n" + + version_file.write_text("8.6.1\n") + assert apply_policy(tmp_path, policy).changes == () + assert version_file.read_text() == "8.6.1\n" + + +def test_synchronize_bazel_dependencies_migrates_modules_and_all_build_files( + tmp_path: Path, +) -> None: + (tmp_path / "MODULE.bazel").write_text( + """bazel_dep( + name = "score_platform", + version = "0.6.3", +) +bazel_dep(name = "score_docs_as_code", version = "7.4.0") +bazel_dep(name = "score_process", version = "1.8.2") +""" + ) + (tmp_path / "BUILD").write_text( + 'deps = ["@score_process//:api", "score_process_description"]\n' + ) + nested = tmp_path / "nested" / "BUILD.bazel" + nested.parent.mkdir() + nested.write_text('load("@score_process//:defs.bzl", "score_rule")\n') + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeBazelDependencies( + Path("MODULE.bazel"), + ( + BazelDependencyUpdate("score_platform", "0.7.0"), + BazelDependencyUpdate("score_docs_as_code", "8.0.0"), + BazelDependencyUpdate( + "score_process", "2.1.0", "score_process_description" + ), + ), + ), + ), + ) + + evaluation = apply_policy(tmp_path, policy) + + assert [change.path for change in evaluation.changes] == [ + Path("MODULE.bazel"), + Path("BUILD"), + Path("nested/BUILD.bazel"), + ] + module = (tmp_path / "MODULE.bazel").read_text() + assert 'name = "score_platform"' in module and 'version = "0.7.0"' in module + assert 'name = "score_docs_as_code"' in module and 'version = "8.0.0"' in module + assert ( + 'name = "score_process_description"' in module and 'version = "2.1.0"' in module + ) + assert "score_process_description" in (tmp_path / "BUILD").read_text() + assert "score_process_description" in nested.read_text() + assert "@score_process//" not in (tmp_path / "BUILD").read_text() + assert "@score_process//" not in nested.read_text() + assert apply_policy(tmp_path, policy).changes == () + + +def test_synchronize_bazel_dependencies_skips_absent_optional_modules( + tmp_path: Path, +) -> None: + (tmp_path / "MODULE.bazel").write_text( + 'bazel_dep(name = "score_platform", version = "0.6.3")\n' + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeBazelDependencies( + Path("MODULE.bazel"), + ( + BazelDependencyUpdate("score_platform", "0.7.0", optional=True), + BazelDependencyUpdate("score_docs_as_code", "8.0.0", optional=True), + ), + ), + ), + ) + + apply_policy(tmp_path, policy) + + assert (tmp_path / "MODULE.bazel").read_text() == ( + 'bazel_dep(name = "score_platform", version = "0.7.0")\n' + ) + + +def test_synchronize_bazel_dependencies_adds_and_updates_git_override( + tmp_path: Path, +) -> None: + module_file = tmp_path / "MODULE.bazel" + module_file.write_text('bazel_dep(name = "score_baselibs", version = "0.2.11")\n') + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeBazelDependencies( + Path("MODULE.bazel"), + ( + BazelDependencyUpdate( + "score_baselibs", + "0.2.11", + override="bf0020fefef402642dcb0092832e03ba4267d739", + remote="https://github.com/eclipse-score/baselibs.git", + ), + ), + ), + ), + ) + + evaluation = apply_policy(tmp_path, policy) + + assert evaluation.changes == ( + Change( + Path("MODULE.bazel"), + "synchronize Bazel dependency versions and module names", + ), + ) + assert module_file.read_text() == ( + 'bazel_dep(name = "score_baselibs", version = "0.2.11")\n\n' + "git_override(\n" + ' module_name = "score_baselibs",\n' + ' commit = "bf0020fefef402642dcb0092832e03ba4267d739",\n' + ' remote = "https://github.com/eclipse-score/baselibs.git",\n' + ")\n" + ) + assert apply_policy(tmp_path, policy).changes == () + + module_file.write_text( + module_file.read_text().replace( + "bf0020fefef402642dcb0092832e03ba4267d739", "old-commit" + ) + ) + assert apply_policy(tmp_path, policy).changes == ( + Change( + Path("MODULE.bazel"), + "synchronize Bazel dependency versions and module names", + ), + ) + assert "old-commit" not in module_file.read_text() + + module_file.write_text( + module_file.read_text().replace( + ' remote = "https://github.com/eclipse-score/baselibs.git",\n', "" + ) + ) + apply_policy(tmp_path, policy) + assert ( + 'remote = "https://github.com/eclipse-score/baselibs.git"' + in module_file.read_text() + ) + + +def test_synchronize_bazel_dependencies_preserves_override_for_newer_version( + tmp_path: Path, +) -> None: + module_file = tmp_path / "MODULE.bazel" + module_file.write_text( + 'bazel_dep(name = "score_baselibs", version = "0.2.12")\n\n' + "git_override(\n" + ' module_name = "score_baselibs",\n' + ' commit = "newer-commit",\n' + ' remote = "https://github.com/eclipse-score/baselibs.git",\n' + ")\n" + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeBazelDependencies( + Path("MODULE.bazel"), + ( + BazelDependencyUpdate( + "score_baselibs", + "0.2.11", + override="baseline-commit", + remote="https://github.com/eclipse-score/baselibs.git", + ), + ), + ), + ), + ) + + assert apply_policy(tmp_path, policy).changes == () + assert 'version = "0.2.12"' in module_file.read_text() + assert 'commit = "newer-commit"' in module_file.read_text() + + +def test_synchronize_bazel_dependencies_ignores_commented_override( + tmp_path: Path, +) -> None: + module_file = tmp_path / "MODULE.bazel" + module_file.write_text( + '# git_override(module_name = "score_baselibs", commit = "commented", ' + 'remote = "https://example.invalid/baselibs.git")\n' + 'bazel_dep(name = "score_baselibs", version = "0.2.11")\n' + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeBazelDependencies( + Path("MODULE.bazel"), + ( + BazelDependencyUpdate( + "score_baselibs", + "0.2.11", + override="baseline-commit", + remote="https://example.invalid/baselibs.git", + ), + ), + ), + ), + ) + + apply_policy(tmp_path, policy) + + module = module_file.read_text() + assert 'commit = "baseline-commit"' in module + assert ( + '# git_override(module_name = "score_baselibs", commit = "commented"' in module + ) + + +def test_bazel_dependency_policy_preserves_newer_baselibs_override( + tmp_path: Path, +) -> None: + module_file = tmp_path / "MODULE.bazel" + module_file.write_text( + 'bazel_dep(name = "score_platform", version = "0.6.3")\n' + 'bazel_dep(name = "score_baselibs", version = "0.2.12")\n\n' + "git_override(\n" + ' module_name = "score_baselibs",\n' + ' commit = "newer-commit",\n' + ' remote = "https://github.com/eclipse-score/baselibs.git",\n' + ")\n" + ) + policy = load_policy( + BUNDLED_POLICY_DIRECTORY / "score-bazel-dependency-alignment" / "policy.yml" + ) + + apply_policy(tmp_path, policy) + + module = module_file.read_text() + assert 'version = "0.7.0"' in module + assert 'version = "0.2.12"' in module + assert 'commit = "newer-commit"' in module + + +def test_synchronize_bazel_dependencies_uses_configured_build_rename( + tmp_path: Path, +) -> None: + (tmp_path / "MODULE.bazel").write_text( + 'bazel_dep(name = "legacy_module", version = "1.0.0")\n' + ) + build = tmp_path / "BUILD" + build.write_text('deps = ["@legacy_module//:api", "legacy_module_description"]\n') + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeBazelDependencies( + Path("MODULE.bazel"), + (BazelDependencyUpdate("legacy_module", "2.0.0", "current_module"),), + ), + ), + ) + + evaluation = apply_policy(tmp_path, policy) + + assert evaluation.changes == ( + Change( + Path("MODULE.bazel"), + "synchronize Bazel dependency versions and module names", + ), + Change( + Path("BUILD"), + "replace 'legacy_module' with 'current_module' in BUILD files", + ), + ) + assert 'name = "current_module"' in (tmp_path / "MODULE.bazel").read_text() + assert 'version = "2.0.0"' in (tmp_path / "MODULE.bazel").read_text() + assert "@current_module//:api" in build.read_text() + assert "legacy_module_description" in build.read_text() + + +def test_synchronize_bazel_dependencies_does_not_change_unconfigured_build_names( + tmp_path: Path, +) -> None: + (tmp_path / "MODULE.bazel").write_text( + 'bazel_dep(name = "score_platform", version = "0.6.3")\n' + ) + build = tmp_path / "BUILD" + build.write_text('deps = ["@score_process//:api"]\n') + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeBazelDependencies( + Path("MODULE.bazel"), + (BazelDependencyUpdate("score_platform", "0.7.0"),), + ), + ), + ) + + evaluation = apply_policy(tmp_path, policy) + + assert evaluation.changes == ( + Change( + Path("MODULE.bazel"), + "synchronize Bazel dependency versions and module names", + ), + ) + assert build.read_text() == 'deps = ["@score_process//:api"]\n' + + +def test_bazel_dependency_policy_does_not_trigger_on_build_reference_alone( + tmp_path: Path, +) -> None: + (tmp_path / "MODULE.bazel").write_text( + """module(name = "example") +bazel_dep(name = "score_platform", version = "0.7.0") +bazel_dep(name = "score_docs_as_code", version = "8.0.0") +bazel_dep(name = "score_process_description", version = "2.1.1") +""" + ) + nested = tmp_path / "subproject" / "BUILD" + nested.parent.mkdir() + nested.write_text('deps = ["@score_process//:api"]\n') + + policy = load_policy( + BUNDLED_POLICY_DIRECTORY / "score-bazel-dependency-alignment" / "policy.yml" + ) + + evaluation = evaluate_policy(tmp_path, policy) + + assert evaluation.applies is False + assert evaluation.changes == () + + +def test_minimal_bazel_module_policy_handles_inline_metadata(tmp_path: Path) -> None: + (tmp_path / "MODULE.bazel").write_text( + 'module(name = "score_sbom", version = "0.0.1")\n' + ) + policy = load_policy( + BUNDLED_POLICY_DIRECTORY / "minimal-bazel-module-declaration" / "policy.yml" + ) + + evaluation = apply_policy(tmp_path, policy) + + assert len(evaluation.changes) == 1 + assert evaluation.changes[0].path == Path("MODULE.bazel") + assert evaluation.changes[0].description == "replace matching text" + assert (tmp_path / "MODULE.bazel").read_text() == 'module(name = "score_sbom")\n' + + +def test_synchronize_file_replaces_contents_and_makes_the_target_executable( + tmp_path: Path, +) -> None: + target = tmp_path / ".devcontainer/run-tool" + target.parent.mkdir() + target.write_text("outdated\n") + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeFile( + Path(".devcontainer/run-tool"), "#!/usr/bin/env bash\ncurrent\n", True + ), + ), + ) + + evaluation = apply_policy(tmp_path, policy) + + assert evaluation.changes == ( + Change( + Path(".devcontainer/run-tool"), "synchronize contents and make executable" + ), + ) + assert target.read_text() == "#!/usr/bin/env bash\ncurrent\n" + assert target.stat().st_mode & 0o111 + assert apply_policy(tmp_path, policy).changes == () + + +def test_synchronize_file_inserts_missing_name_and_preserves_workflow_jobs( + tmp_path: Path, +) -> None: + target = tmp_path / ".github/workflows/docs.yml" + target.parent.mkdir(parents=True) + target.write_text( + "# local header\n" + "on:\n" + " push:\n" + " branches: [main]\n" + "jobs:\n" + " local:\n" + " runs-on: ubuntu-latest\n" + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeFile( + path=Path(".github/workflows/docs.yml"), + contents=( + "name: Documentation CI\n" + "on:\n" + " pull_request:\n" + "jobs:\n" + " docs:\n" + " uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@ref\n" + ), + preserve_reusable_workflow_refs=( + ( + "eclipse-score/cicd-workflows/.github/workflows/docs.yml", + (0, 0, 3), + ), + ), + preserve_workflow_content=True, + ), + ), + ) + + apply_policy(tmp_path, policy) + + result = target.read_text() + assert result.startswith("# local header\nname: Documentation CI\n") + assert " local:\n" in result + assert " docs:\n" in result + + +def test_synchronize_file_uses_source_workflow_permissions( + tmp_path: Path, +) -> None: + target = tmp_path / ".github/workflows/docs.yml" + target.parent.mkdir(parents=True) + target.write_text( + "name: Local\n" + "permissions:\n" + " contents: write\n" + "on: [push]\n" + "jobs:\n" + " docs:\n" + " uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@old\n" + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeFile( + path=Path(".github/workflows/docs.yml"), + contents=( + "name: Documentation\n" + "permissions:\n" + " contents: read\n" + "on: [workflow_dispatch]\n" + "jobs:\n" + " docs:\n" + " uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@new\n" + ), + preserve_reusable_workflow_refs=( + ( + "eclipse-score/cicd-workflows/.github/workflows/docs.yml", + (0, 0, 3), + ), + ), + preserve_workflow_content=True, + ), + ), + ) + + apply_policy(tmp_path, policy) + + result = target.read_text() + assert "permissions:\n contents: read\n" in result + assert "contents: write" not in result + + +def test_synchronize_file_keeps_workflow_sections_separated_without_source_newline( + tmp_path: Path, +) -> None: + target = tmp_path / ".github/workflows/docs.yml" + target.parent.mkdir(parents=True) + target.write_text( + "name: Local\non: [push]\njobs:\n local:\n runs-on: ubuntu-latest\n" + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeFile( + path=Path(".github/workflows/docs.yml"), + contents="name: Documentation CI\non: [workflow_dispatch]", + preserve_workflow_content=True, + ), + ), + ) + + apply_policy(tmp_path, policy) + + assert target.read_text() == ( + "name: Documentation CI\n" + "on: [workflow_dispatch]\n" + "jobs:\n" + " local:\n" + " runs-on: ubuntu-latest\n" + ) + + +def test_synchronize_file_rejects_workflow_job_id_collision(tmp_path: Path) -> None: + target = tmp_path / ".github/workflows/docs.yml" + target.parent.mkdir(parents=True) + target.write_text( + "name: Local\non: [push]\njobs:\n docs:\n runs-on: ubuntu-latest\n" + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeFile( + path=Path(".github/workflows/docs.yml"), + contents=( + "name: Documentation CI\n" + "on: [push]\n" + "jobs:\n" + " docs:\n" + " uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@ref\n" + ), + preserve_reusable_workflow_refs=( + ( + "eclipse-score/cicd-workflows/.github/workflows/docs.yml", + (0, 0, 3), + ), + ), + preserve_workflow_content=True, + ), + ), + ) + + with pytest.raises(RepoPolicySyncError, match="job with that ID already exists"): + apply_policy(tmp_path, policy) + + +def test_synchronize_file_rejects_four_space_workflow_job_id_collision( + tmp_path: Path, +) -> None: + target = tmp_path / ".github/workflows/docs.yml" + target.parent.mkdir(parents=True) + target.write_text( + "name: Local\non: [push]\njobs:\n docs:\n runs-on: ubuntu-latest\n" + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeFile( + path=Path(".github/workflows/docs.yml"), + contents=( + "name: Documentation CI\n" + "on: [push]\n" + "jobs:\n" + " docs:\n" + " uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@ref\n" + ), + preserve_reusable_workflow_refs=( + ( + "eclipse-score/cicd-workflows/.github/workflows/docs.yml", + (0, 0, 3), + ), + ), + preserve_workflow_content=True, + ), + ), + ) + + with pytest.raises(RepoPolicySyncError, match="job with that ID already exists"): + apply_policy(tmp_path, policy) + + +def test_synchronize_file_preserves_publish_permissions_on_matching_job( + tmp_path: Path, +) -> None: + target = tmp_path / ".github/workflows/docs-publish.yml" + target.parent.mkdir(parents=True) + target.write_text( + "name: Publish Documentation\n" + "on: [workflow_run]\n" + "jobs:\n" + " publish:\n" + " uses: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml@v0.0.2\n" + " with:\n" + " deployment_type: custom" + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeFile( + path=Path(".github/workflows/docs-publish.yml"), + contents=( + "name: Publish Documentation\n" + "on:\n" + " workflow_run:\n" + " workflows: [Documentation CI]\n" + "jobs:\n" + " docs-publish:\n" + " uses: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml@ref\n" + " permissions:\n" + " contents: write\n" + " pages: write\n" + ), + preserve_reusable_workflow_refs=( + ( + "eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml", + (0, 0, 3), + ), + ), + preserve_workflow_content=True, + ), + ), + ) + + apply_policy(tmp_path, policy) + + result = target.read_text() + assert "contents: write\n" in result + assert "pages: write\n" in result + assert "deployment_type: custom\n permissions:" in result + assert "deployment_type: custom\n" in result + + +def test_after_apply_regenerates_existing_conditional_file( + tmp_path: Path, monkeypatch +) -> None: + (tmp_path / "MODULE.bazel.lock").write_text("old lock\n") + policy = Policy( + id="example", + title="Example", + description=None, + bazel_condition=None, + ensure=(EnsureLine(Path(".bazelversion"), "8.6.0", ()),), + after_apply=( + AfterApplyCommand( + ("bazel", "mod", "deps"), + Path("MODULE.bazel.lock"), + "Regenerate the lock file.", + ), + ), + ) + calls: list[tuple[tuple[str, ...], Path]] = [] + + def run(command, *, cwd, check, capture_output, text, env): + calls.append((tuple(command), cwd)) + assert capture_output is True + assert text is True + assert env["GIT_CONFIG_NOSYSTEM"] == "1" + assert "GH_TOKEN" not in env + + monkeypatch.setattr("repo_policy_sync.engine.subprocess.run", run) + + evaluation = evaluate_policy(tmp_path, policy) + applied = apply_policy(tmp_path, policy) + + assert [change.path for change in evaluation.changes] == [ + Path(".bazelversion"), + Path("MODULE.bazel.lock"), + ] + assert applied == evaluation + assert calls == [(("bazel", "mod", "deps"), tmp_path)] + + +def test_force_after_apply_runs_for_an_already_compliant_policy( + tmp_path: Path, monkeypatch +) -> None: + lock_file = tmp_path / "MODULE.bazel.lock" + lock_file.write_text("old lock\n") + (tmp_path / ".bazelversion").write_text("8.6.0\n") + policy = Policy( + id="example", + title="Example", + description=None, + bazel_condition=None, + ensure=(EnsureMinimumVersion(Path(".bazelversion"), "8.6.0"),), + after_apply=( + AfterApplyCommand( + ("bazel", "mod", "deps"), Path("MODULE.bazel.lock"), "Regenerate lock." + ), + ), + ) + + def run(command, *, cwd, check, capture_output, text, env): + assert command == ("bazel", "mod", "deps") + assert cwd == tmp_path + assert capture_output is True + assert text is True + assert env["GIT_CONFIG_NOSYSTEM"] == "1" + lock_file.write_text("new lock\n") + + monkeypatch.setattr("repo_policy_sync.engine.subprocess.run", run) + + applied = apply_policy(tmp_path, policy, force_after_apply=True) + + assert applied.changes == (Change(Path("MODULE.bazel.lock"), "Regenerate lock."),) + assert lock_file.read_text() == "new lock\n" + + +def test_after_apply_failure_redacts_credentials(tmp_path: Path, monkeypatch) -> None: + (tmp_path / "MODULE.bazel.lock").write_text("old lock\n") + policy = Policy( + id="example", + title="Example", + description=None, + bazel_condition=None, + ensure=(EnsureLine(Path(".bazelversion"), "8.6.0", ()),), + after_apply=( + AfterApplyCommand( + ("bazel", "mod", "deps", "--token", "ghp_secret_value_12345"), + Path("MODULE.bazel.lock"), + "Regenerate the lock file.", + ), + ), + ) + + def run(*_, **__): + raise subprocess.CalledProcessError( + 1, + ["bazel", "mod", "deps"], + stderr="authorization: Bearer ghp_secret_value_12345\n", + output="password=another-secret\n", + ) + + monkeypatch.setattr("repo_policy_sync.engine.subprocess.run", run) + + with pytest.raises(RepoPolicySyncError) as error: + apply_policy(tmp_path, policy) + + message = str(error.value) + assert "ghp_secret_value_12345" not in message + assert "another-secret" not in message + assert "[REDACTED]" in message + + +def test_after_apply_failure_without_output_has_a_fallback_message( + tmp_path: Path, monkeypatch +) -> None: + (tmp_path / "MODULE.bazel.lock").write_text("old lock\n") + policy = Policy( + id="example", + title="Example", + description=None, + bazel_condition=None, + ensure=(EnsureLine(Path(".bazelversion"), "8.6.0", ()),), + after_apply=( + AfterApplyCommand( + ("bazel", "mod", "deps"), + Path("MODULE.bazel.lock"), + "Regenerate the lock file.", + ), + ), + ) + + def run(*_, **__): + raise subprocess.CalledProcessError(1, ["bazel", "mod", "deps"]) + + monkeypatch.setattr("repo_policy_sync.engine.subprocess.run", run) + + with pytest.raises(RepoPolicySyncError, match=r"command failed \(exit status 1\)"): + apply_policy(tmp_path, policy) + + +def _devcontainer_policy(*, with_guard: bool = False) -> Policy: + return Policy( + id="example", + title="Example", + description=None, + bazel_condition=BazelCondition(("score_devcontainer",)), + ensure=( + SynchronizeDevcontainerVersion( + Path(".devcontainer/Dockerfile"), + Path("MODULE.bazel"), + "ghcr.io/eclipse-score/devcontainer", + "score_devcontainer", + ), + ), + after_apply=( + AfterApplyCommand( + ("bazel", "mod", "deps"), + Path("MODULE.bazel.lock"), + "Regenerate lock.", + Path("MODULE.bazel") if with_guard else None, + ), + ), + file_contains_condition=FileContainsCondition( + Path(".devcontainer/Dockerfile"), + r"(?m)^\s*FROM\s+ghcr\.io/eclipse-score/devcontainer:", + ), + ) + + +def _write_devcontainer_files( + tmp_path: Path, docker_version: str, module_version: str +) -> None: + (tmp_path / ".devcontainer").mkdir(exist_ok=True) + (tmp_path / ".devcontainer/Dockerfile").write_text( + f"FROM ghcr.io/eclipse-score/devcontainer:v{docker_version} AS development\nRUN echo ready\n" + ) + (tmp_path / "MODULE.bazel").write_text( + 'module(name = "example")\n\n' + "bazel_dep(\n" + f' version = "{module_version}",\n' + ' name = "score_devcontainer",\n' + ")\n" + ) + + +def test_devcontainer_policy_updates_only_the_lower_version(tmp_path: Path) -> None: + _write_devcontainer_files(tmp_path, "1.9.0", "1.8.4") + + evaluation = apply_policy(tmp_path, _devcontainer_policy()) + + assert evaluation.changes == ( + Change(Path("MODULE.bazel"), "align version from '1.8.4' to '1.9.0'"), + ) + assert 'version = "1.9.0"' in (tmp_path / "MODULE.bazel").read_text() + assert ( + (tmp_path / ".devcontainer/Dockerfile") + .read_text() + .endswith("AS development\nRUN echo ready\n") + ) + + +def test_devcontainer_policy_updates_dockerfile_when_bazel_is_higher( + tmp_path: Path, +) -> None: + _write_devcontainer_files(tmp_path, "1.8.4", "1.9.0") + + evaluation = apply_policy(tmp_path, _devcontainer_policy()) + + assert evaluation.changes == ( + Change( + Path(".devcontainer/Dockerfile"), "align version from '1.8.4' to '1.9.0'" + ), + ) + assert ( + "FROM ghcr.io/eclipse-score/devcontainer:v1.9.0 AS development" + in (tmp_path / ".devcontainer/Dockerfile").read_text() + ) + + +def test_devcontainer_policy_is_not_applicable_without_target_base_image( + tmp_path: Path, +) -> None: + (tmp_path / ".devcontainer").mkdir() + (tmp_path / ".devcontainer/Dockerfile").write_text("FROM ubuntu:24.04\n") + (tmp_path / "MODULE.bazel").write_text( + 'bazel_dep(name = "score_devcontainer", version = "1.9.0")\n' + ) + + assert evaluate_policy(tmp_path, _devcontainer_policy()).applies is False + + +def test_devcontainer_policy_is_not_applicable_without_direct_dependency( + tmp_path: Path, +) -> None: + _write_devcontainer_files(tmp_path, "1.8.4", "1.9.0") + (tmp_path / "MODULE.bazel").write_text( + 'bazel_dep(name = "other", version = "1.9.0")\n' + ) + + assert evaluate_policy(tmp_path, _devcontainer_policy()).applies is False + + +def test_devcontainer_standardization_is_not_applicable_without_module_file( + tmp_path: Path, +) -> None: + (tmp_path / ".devcontainer").mkdir() + (tmp_path / ".devcontainer/Dockerfile").write_text( + "FROM ghcr.io/eclipse-score/devcontainer:v1.9.0\n" + ) + policy = load_policy( + BUNDLED_POLICY_DIRECTORY / "score-devcontainer-standardization" / "policy.yml" + ) + + assert evaluate_policy(tmp_path, policy).applies is False + + +def test_devcontainer_standardization_does_not_rewrite_similar_paths( + tmp_path: Path, +) -> None: + policy = load_policy( + BUNDLED_POLICY_DIRECTORY / "score-devcontainer-standardization" / "policy.yml" + ) + (tmp_path / ".devcontainer").mkdir() + (tmp_path / ".devcontainer/Dockerfile").write_text( + "FROM ghcr.io/eclipse-score/devcontainer:v1.9.0\n" + ) + (tmp_path / "MODULE.bazel").write_text( + 'bazel_dep(name = "score_devcontainer", version = "1.9.0")\n' + ) + pre_commit = tmp_path / ".pre-commit-config.yaml" + pre_commit.write_text("entry: custom-tools/run_tool.sh actionlint\n") + + apply_policy(tmp_path, policy) + + assert pre_commit.read_text() == "entry: custom-tools/run_tool.sh actionlint\n" + + +def test_devcontainer_policy_rejects_unsupported_or_duplicate_declarations( + tmp_path: Path, +) -> None: + _write_devcontainer_files(tmp_path, "1.9", "1.8.4") + dockerfile = tmp_path / ".devcontainer/Dockerfile" + module_file = tmp_path / "MODULE.bazel" + before_dockerfile = dockerfile.read_text() + before_module = module_file.read_text() + + with pytest.raises(RepoPolicySyncError, match="vX.Y.Z"): + apply_policy(tmp_path, _devcontainer_policy()) + + assert dockerfile.read_text() == before_dockerfile + assert module_file.read_text() == before_module + + _write_devcontainer_files(tmp_path, "1.9.0", "1.8.4") + module_file.write_text( + module_file.read_text() + + 'bazel_dep(name = "score_devcontainer", version = "1.9.0")\n' + ) + before_module = module_file.read_text() + with pytest.raises(RepoPolicySyncError, match="exactly one bazel_dep"): + apply_policy(tmp_path, _devcontainer_policy()) + assert module_file.read_text() == before_module + + _write_devcontainer_files(tmp_path, "1.9.0", "1.8.4") + module_file.write_text('bazel_dep(name = "score_devcontainer")\n') + before_module = module_file.read_text() + with pytest.raises(RepoPolicySyncError, match="must declare version"): + apply_policy(tmp_path, _devcontainer_policy()) + assert module_file.read_text() == before_module + + +def test_after_apply_changed_path_guard_only_regenerates_lock_after_module_change( + tmp_path: Path, monkeypatch +) -> None: + lock_file = tmp_path / "MODULE.bazel.lock" + calls: list[tuple[str, ...]] = [] + + def run(command, *, cwd, check, capture_output, text, env): + calls.append(tuple(command)) + assert capture_output is True + assert text is True + assert env["GIT_CONFIG_NOSYSTEM"] == "1" + + monkeypatch.setattr("repo_policy_sync.engine.subprocess.run", run) + _write_devcontainer_files(tmp_path, "1.9.0", "1.8.4") + lock_file.write_text("old lock\n") + applied = apply_policy(tmp_path, _devcontainer_policy(with_guard=True)) + + assert [change.path for change in applied.changes] == [ + Path("MODULE.bazel"), + Path("MODULE.bazel.lock"), + ] + assert calls == [("bazel", "mod", "deps")] + + calls.clear() + _write_devcontainer_files(tmp_path, "1.8.4", "1.9.0") + apply_policy(tmp_path, _devcontainer_policy(with_guard=True)) + + assert calls == [] + + +def test_devcontainer_migration_adds_copyright_only_for_eclipse_score( + tmp_path: Path, +) -> None: + policy = load_policy( + BUNDLED_POLICY_DIRECTORY + / "score-devcontainer-dockerfile-migration" + / "policy.yml" + ) + + for organization, expected_copyright in ((None, False), ("eclipse-score", True)): + repository = tmp_path / (organization or "other-org") + repository.mkdir() + (repository / ".devcontainer.json").write_text( + '{\n "image": "ghcr.io/eclipse-score/devcontainer:v1.9.0"\n}\n' + ) + + apply_policy(repository, policy, organization=organization) + + dockerfile = (repository / ".devcontainer/Dockerfile").read_text() + assert ( + dockerfile.startswith( + "# *******************************************************************************\n" + ) + is expected_copyright + ) + assert "# Use Dockerfile to get dependabot version bumps" in dockerfile + assert not (repository / ".devcontainer.json").exists() + config = (repository / ".devcontainer/devcontainer.json").read_text() + assert '"dockerfile": "Dockerfile"' in config + assert '"context"' not in config + + +def test_devcontainer_migration_handles_compact_jsonc_image_property( + tmp_path: Path, +) -> None: + policy = load_policy( + BUNDLED_POLICY_DIRECTORY + / "score-devcontainer-dockerfile-migration" + / "policy.yml" + ) + (tmp_path / ".devcontainer.json").write_text( + '{"image":"ghcr.io/eclipse-score/devcontainer:v1.9.0",' + '"custom":{"label":"value"}}' + ) + + apply_policy(tmp_path, policy) + + destination = tmp_path / ".devcontainer/devcontainer.json" + assert not (tmp_path / ".devcontainer.json").exists() + assert '"dockerfile": "Dockerfile"' in destination.read_text() + + +def test_devcontainer_migration_rejects_root_config_with_relative_paths( + tmp_path: Path, +) -> None: + policy = load_policy( + BUNDLED_POLICY_DIRECTORY + / "score-devcontainer-dockerfile-migration" + / "policy.yml" + ) + (tmp_path / ".devcontainer.json").write_text( + "{\n" + ' "image": "ghcr.io/eclipse-score/devcontainer:v1.9.0",\n' + ' "mounts": ["source=./cache,target=/cache"]\n' + "}\n" + ) + + with pytest.raises(RepoPolicySyncError, match="relative paths"): + apply_policy(tmp_path, policy) + + assert (tmp_path / ".devcontainer.json").exists() + assert not (tmp_path / ".devcontainer/devcontainer.json").exists() + + +def test_ensure_no_such_file_removes_dangling_symlink(tmp_path: Path) -> None: + link = tmp_path / "legacy" + link.symlink_to("missing-file") + policy = Policy( + "example", + "Example", + None, + None, + (EnsureNoSuchFile(Path("legacy")),), + ) + + evaluation = apply_policy(tmp_path, policy) + + assert evaluation.changes == (Change(Path("legacy"), "remove file"),) + assert not link.exists() + assert not link.is_symlink() + + +def test_operations_reject_symlink_to_path_outside_checkout(tmp_path: Path) -> None: + outside = tmp_path.parent / "repo-policy-sync-outside" + outside.mkdir() + try: + (outside / "target").write_text("outside\n") + (tmp_path / "target").symlink_to(outside / "target") + policy = Policy( + "example", + "Example", + None, + None, + (EnsureLine(Path("target"), "inside", ()),), + ) + + with pytest.raises(RepoPolicySyncError, match="symbolic link"): + evaluate_policy(tmp_path, policy) + finally: + (outside / "target").unlink(missing_ok=True) + outside.rmdir() + + +def test_synchronize_bazel_dependencies_skips_unrelated_symlink( + tmp_path: Path, +) -> None: + outside = tmp_path.parent / f"{tmp_path.name}-outside" + outside.mkdir() + try: + (outside / "file").write_text("unrelated\n") + (tmp_path / "unrelated-link").symlink_to(outside / "file") + module_file = tmp_path / "MODULE.bazel" + module_file.write_text( + 'bazel_dep(name = "score_platform", version = "0.6.0")\n' + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeBazelDependencies( + Path("MODULE.bazel"), + (BazelDependencyUpdate("score_platform", "0.7.0"),), + ), + ), + ) + + apply_policy(tmp_path, policy) + + assert 'version = "0.7.0"' in module_file.read_text() + finally: + (outside / "file").unlink(missing_ok=True) + outside.rmdir() diff --git a/repo_policy_sync/tests/test_github.py b/repo_policy_sync/tests/test_github.py new file mode 100644 index 0000000..f304105 --- /dev/null +++ b/repo_policy_sync/tests/test_github.py @@ -0,0 +1,1102 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from repo_policy_sync.github import ( + CommitResult, + GitHubCli, + PullRequest, + _pull_request_body, + policy_branches, +) +from repo_policy_sync.errors import CommandError, redact_sensitive_text +from repo_policy_sync.models import ( + BazelCondition, + Change, + EnsureLine, + EnsureNoSuchFile, + Policy, + Repository, +) +from repo_policy_sync.policy import BUNDLED_POLICY_DIRECTORY, load_policy + + +def test_commit_stages_deleted_policy_files(monkeypatch, tmp_path: Path) -> None: + commands: list[list[str]] = [] + + def record(command: list[str]) -> str: + commands.append(command) + return "" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + policy = Policy( + id="example", + title="Example", + description=None, + bazel_condition=None, + ensure=( + EnsureLine(Path(".gitignore"), "_build", ()), + EnsureNoSuchFile(Path("docs/ubproject.toml")), + ), + ) + + GitHubCli().commit_and_push( + checkout=tmp_path, + branch="repo-policy-sync/example", + policy=policy, + changes=( + Change(Path(".gitignore"), "add '_build'"), + Change(Path("docs/ubproject.toml"), "remove file"), + ), + ) + + assert commands[0] == [ + "git", + "-C", + str(tmp_path), + "add", + "-A", + "--", + ".gitignore", + "docs/ubproject.toml", + ] + + +def test_commit_does_not_stage_policy_paths_without_changes( + monkeypatch, tmp_path: Path +) -> None: + commands: list[list[str]] = [] + + def record(command: list[str]) -> str: + commands.append(command) + return "" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + policy = Policy("example", "Example", None, None, ()) + + GitHubCli().commit_and_push( + checkout=tmp_path, + branch="repo-policy-sync/example", + policy=policy, + changes=(Change(Path(".gitignore"), "add '_build'"),), + ) + + assert commands[0] == ["git", "-C", str(tmp_path), "add", "-A", "--", ".gitignore"] + + +def test_has_changes_detects_untracked_policy_files( + monkeypatch, tmp_path: Path +) -> None: + commands: list[list[str]] = [] + + def record(command: list[str]) -> str: + commands.append(command) + return "?? generated.txt\n" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + + assert GitHubCli().has_changes( + checkout=tmp_path, + changes=(Change(Path("generated.txt"), "add generated file"),), + ) + assert commands == [ + [ + "git", + "-C", + str(tmp_path), + "status", + "--short", + "--untracked-files=all", + "--", + "generated.txt", + ] + ] + + +def test_cached_checkout_rejects_a_different_origin( + monkeypatch, tmp_path: Path +) -> None: + (tmp_path / ".git").mkdir() + commands: list[list[str]] = [] + + def record(command: list[str]) -> str: + commands.append(command) + if command[:4] == ["gh", "repo", "view", "owner/repository"]: + return "https://github.com/owner/repository\n" + if command[-3:] == ["remote", "get-url", "origin"]: + return "git@github.com:other/repository.git\n" + return "" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + + with pytest.raises(CommandError, match="does not match"): + GitHubCli().sync_default_branch( + repository="owner/repository", + branch="main", + destination=tmp_path, + ) + + assert not any(command[4:5] == ["fetch"] for command in commands) + + +def test_commit_runs_pre_commit_after_staging_when_repository_configures_it( + monkeypatch, tmp_path: Path +) -> None: + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + (tmp_path / ".gitignore").write_text("\n") + commands: list[tuple[list[str], Path | None]] = [] + + def record( + command: list[str], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, + ) -> str: + commands.append((command, cwd)) + return "" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + policy = Policy("example", "Example", None, None, ()) + + GitHubCli().commit_and_push( + checkout=tmp_path, + branch="repo-policy-sync/example", + policy=policy, + changes=(Change(Path(".gitignore"), "add '_build'"),), + ) + + assert commands[0][0] == [ + "git", + "-C", + str(tmp_path), + "add", + "-A", + "--", + ".gitignore", + ] + assert commands[1] == ( + ["pre-commit", "run", "--files", ".gitignore"], + tmp_path, + ) + assert commands[2][0] == [ + "git", + "-C", + str(tmp_path), + "add", + "-A", + "--", + ".gitignore", + ] + + +def test_pre_commit_does_not_inherit_credentials_or_user_configuration( + monkeypatch, tmp_path: Path +) -> None: + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + observed: dict[str, str] = {} + monkeypatch.setenv("GH_TOKEN", "secret") + monkeypatch.setenv("GITHUB_TOKEN", "secret-too") + monkeypatch.setenv("GH_CONFIG_DIR", "/tmp/user-gh-config") + + def record( + command: list[str], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, + ) -> str: + if command[0] == "pre-commit": + assert env is not None + observed.update(env) + assert Path(env["HOME"]).is_dir() + return "" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + + assert GitHubCli().run_pre_commit(checkout=tmp_path) + + assert "GH_TOKEN" not in observed + assert "GITHUB_TOKEN" not in observed + assert observed["GH_CONFIG_DIR"] != os.environ["GH_CONFIG_DIR"] + assert observed["GIT_CONFIG_NOSYSTEM"] == "1" + assert observed["GIT_TERMINAL_PROMPT"] == "0" + + +def test_pre_commit_failure_stops_commit_and_push(monkeypatch, tmp_path: Path) -> None: + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + (tmp_path / ".gitignore").write_text("\n") + commands: list[tuple[list[str], Path | None]] = [] + + def record( + command: list[str], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, + ) -> str: + commands.append((command, cwd)) + if command[0] == "pre-commit": + raise CommandError("pre-commit found issues") + return "" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + policy = Policy("example", "Example", None, None, ()) + + with pytest.raises(CommandError, match="pre-commit found issues"): + GitHubCli().commit_and_push( + checkout=tmp_path, + branch="repo-policy-sync/example", + policy=policy, + changes=(Change(Path(".gitignore"), "add '_build'"),), + ) + + assert [command[0][0] for command in commands] == [ + "git", + "pre-commit", + "git", + "pre-commit", + ] + + +def test_pre_commit_formatting_fix_is_rechecked_before_publishing( + monkeypatch, tmp_path: Path +) -> None: + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + (tmp_path / ".gitignore").write_text("\n") + commands: list[tuple[list[str], Path | None]] = [] + pre_commit_runs = 0 + + def record( + command: list[str], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, + ) -> str: + nonlocal pre_commit_runs + commands.append((command, cwd)) + if command[0] == "pre-commit": + pre_commit_runs += 1 + if pre_commit_runs == 1: + raise CommandError("pre-commit fixed formatting") + if command[-2:] == ["rev-parse", "HEAD"]: + return "b" * 40 + return "" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + policy = Policy("example", "Example", None, None, ()) + + result = GitHubCli().commit_and_push( + checkout=tmp_path, + branch="repo-policy-sync/example", + policy=policy, + changes=(Change(Path(".gitignore"), "add '_build'"),), + ) + + assert result == CommitResult("b" * 40) + assert pre_commit_runs == 2 + assert commands[2][0] == [ + "git", + "-C", + str(tmp_path), + "add", + "-A", + "--", + ".gitignore", + ] + assert [command[0][0] for command in commands] == [ + "git", + "pre-commit", + "git", + "pre-commit", + "git", + "git", + "git", + "git", + ] + + +def test_dirty_commit_keeps_pre_commit_failure_and_publishes_changes( + monkeypatch, tmp_path: Path +) -> None: + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + (tmp_path / ".gitignore").write_text("\n") + commands: list[tuple[list[str], Path | None]] = [] + + def record( + command: list[str], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, + ) -> str: + commands.append((command, cwd)) + if command[0] == "pre-commit": + raise CommandError("pre-commit found issues") + if command[-2:] == ["rev-parse", "HEAD"]: + return "b" * 40 + return "" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + policy = Policy("example", "Example", None, None, ()) + + result = GitHubCli().commit_and_push( + checkout=tmp_path, + branch="repo-policy-sync/example", + policy=policy, + changes=(Change(Path(".gitignore"), "add '_build'"),), + allow_dirty_pr=True, + ) + + assert result == CommitResult("b" * 40, "pre-commit found issues") + assert [command[0][0] for command in commands] == [ + "git", + "pre-commit", + "git", + "pre-commit", + "git", + "git", + "git", + "git", + ] + + +def test_local_policy_branch_is_reused_after_a_failed_run( + monkeypatch, tmp_path: Path +) -> None: + commands: list[list[str]] = [] + + def record(command: list[str]) -> str: + commands.append(command) + return "" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + + GitHubCli().switch_to_policy_branch( + checkout=tmp_path, + branch="repo-policy-sync/example", + exists_remotely=False, + ) + + assert commands == [ + ["git", "-C", str(tmp_path), "switch", "-C", "repo-policy-sync/example"] + ] + + +def test_restore_synced_default_branch_never_fetches( + monkeypatch, tmp_path: Path +) -> None: + commands: list[list[str]] = [] + + def record(command: list[str]) -> str: + commands.append(command) + return "" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + + GitHubCli().restore_synced_default_branch(checkout=tmp_path) + + assert commands == [ + [ + "git", + "-C", + str(tmp_path), + "checkout", + "--detach", + "--force", + "refs/repo-policy-sync/default", + ], + ["git", "-C", str(tmp_path), "clean", "-fdx"], + ] + + +def test_list_repositories_reads_all_paginated_results(monkeypatch) -> None: + commands: list[list[str]] = [] + + def record(command: list[str]) -> str: + commands.append(command) + return ( + '[[{"name":"first","default_branch":"main","archived":false}],' + '[{"name":"empty","default_branch":null,"archived":true}]]' + ) + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(record)) + + repositories = GitHubCli().list_repositories(org="eclipse-score") + + assert repositories == ( + Repository("first", "main"), + Repository("empty", None, archived=True), + ) + assert commands == [ + ["gh", "api", "--paginate", "--slurp", "/orgs/eclipse-score/repos?per_page=100"] + ] + + +@pytest.mark.parametrize( + "output, message", + [ + ("not-json", "invalid repository JSON"), + ("{}", "invalid repository JSON"), + ("[{}]", "invalid repository JSON"), + ('[[{"name":"","default_branch":"main"}]]', "without a valid name"), + ( + '[[{"name":"repo","default_branch":false}]]', + "invalid default branch", + ), + ('[[{"name":"repo","archived":"no"}]]', "invalid archived state"), + ], +) +def test_list_repositories_rejects_invalid_api_payloads( + monkeypatch, output: str, message: str +) -> None: + monkeypatch.setattr(GitHubCli, "_run", staticmethod(lambda _: output)) + + with pytest.raises(CommandError, match=message): + GitHubCli().list_repositories(org="eclipse-score") + + +def test_gh_command_failures_are_actionable(monkeypatch) -> None: + def run(*_: object, **__: object) -> None: + raise subprocess.CalledProcessError( + 1, ["gh", "auth", "status"], stderr="authentication failed\n" + ) + + monkeypatch.setattr("repo_policy_sync.github.subprocess.run", run) + + with pytest.raises(CommandError, match="gh auth status: authentication failed"): + GitHubCli._run(["gh", "auth", "status"]) + + +def test_gh_command_failures_redact_credentials(monkeypatch) -> None: + token = "ghp_secret_value_12345" + + def run(*_: object, **__: object) -> None: + raise subprocess.CalledProcessError( + 1, + ["gh", "auth", "status"], + stderr=f"Authorization: Bearer {token}\n", + ) + + monkeypatch.setattr("repo_policy_sync.github.subprocess.run", run) + + with pytest.raises(CommandError) as error: + GitHubCli._run(["gh", "auth", "status"]) + + assert token not in str(error.value) + assert "[REDACTED]" in str(error.value) + + +def test_redact_sensitive_text_covers_environment_and_url_credentials( + monkeypatch, +) -> None: + monkeypatch.setenv("GH_TOKEN", "environment-secret-value") + + redacted = redact_sensitive_text( + "GH_TOKEN=environment-secret-value " + "https://user:password-value@example.test/repo " + "--token ghp_secret_value_12345" + ) + + assert "environment-secret-value" not in redacted + assert "password-value" not in redacted + assert "ghp_secret_value_12345" not in redacted + assert redacted.count("[REDACTED]") == 3 + + +def test_missing_gh_command_is_actionable(monkeypatch) -> None: + def run(*_: object, **__: object) -> None: + raise FileNotFoundError + + monkeypatch.setattr("repo_policy_sync.github.subprocess.run", run) + + with pytest.raises(CommandError, match="required command is unavailable: gh"): + GitHubCli._run(["gh", "auth", "status"]) + + +def test_create_pull_request_creates_missing_automation_labels(monkeypatch) -> None: + commands: list[list[str]] = [] + + def run(command: list[str]) -> str: + commands.append(command) + if command[:5] == [ + "gh", + "api", + "--paginate", + "--slurp", + "/repos/owner/repo/labels?per_page=100", + ]: + return "[[]]" + if command[:4] == ["gh", "api", "--method", "POST"]: + return "" + if command[:3] == ["gh", "pr", "create"]: + return "https://github.example/owner/repo/pull/1\n" + if command[:3] == ["gh", "pr", "edit"]: + return "" + raise AssertionError(command) + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(run)) + policy = Policy("example", "Example", None, None, ()) + + pull_request = GitHubCli().create_pull_request( + repository="owner/repo", + base="main", + branch="repo-policy-sync/example", + policy=policy, + changes=(), + head_oid="a" * 40, + ) + + assert pull_request.url == "https://github.example/owner/repo/pull/1" + assert pull_request.warnings == () + assert [ + command[4] + for command in commands + if command[:4] == ["gh", "api", "--method", "POST"] + ] == [ + "/repos/owner/repo/labels", + "/repos/owner/repo/labels", + ] + assert [ + command[6] + for command in commands + if command[:4] == ["gh", "api", "--method", "POST"] + ] == [ + "name=automation", + "name=repo-policy-sync", + ] + assert [ + command[8] + for command in commands + if command[:4] == ["gh", "api", "--method", "POST"] + ] == [ + "color=EDEDED", + "color=EDEDED", + ] + assert [ + command[-1] for command in commands if command[:3] == ["gh", "pr", "edit"] + ] == [ + "automation", + "repo-policy-sync", + ] + + +def test_create_pull_request_keeps_existing_automation_labels(monkeypatch) -> None: + commands: list[list[str]] = [] + + def run(command: list[str]) -> str: + commands.append(command) + if command[:5] == [ + "gh", + "api", + "--paginate", + "--slurp", + "/repos/owner/repo/labels?per_page=100", + ]: + return '[[{"name":"automation"},{"name":"repo-policy-sync"}]]' + if command[:3] == ["gh", "pr", "create"]: + return "https://github.example/owner/repo/pull/1\n" + if command[:3] == ["gh", "pr", "edit"]: + return "" + raise AssertionError(command) + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(run)) + policy = Policy("example", "Example", None, None, ()) + + GitHubCli().create_pull_request( + repository="owner/repo", + base="main", + branch="repo-policy-sync/example", + policy=policy, + changes=(), + head_oid="a" * 40, + ) + + assert not any( + command[:4] == ["gh", "api", "--method", "POST"] for command in commands + ) + + +def test_create_pull_request_can_create_a_draft(monkeypatch) -> None: + commands: list[list[str]] = [] + + def run(command: list[str]) -> str: + commands.append(command) + if command[:5] == [ + "gh", + "api", + "--paginate", + "--slurp", + "/repos/owner/repo/labels?per_page=100", + ]: + return '[[{"name":"automation"},{"name":"repo-policy-sync"}]]' + if command[:3] == ["gh", "pr", "create"]: + return "https://github.example/owner/repo/pull/1\n" + if command[:3] == ["gh", "pr", "edit"]: + return "" + raise AssertionError(command) + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(run)) + policy = Policy("example", "Example", None, None, ()) + + GitHubCli().create_pull_request( + repository="owner/repo", + base="main", + branch="repo-policy-sync/example", + policy=policy, + changes=(), + head_oid="a" * 40, + draft=True, + ) + + assert commands[1][:4] == ["gh", "pr", "create", "--draft"] + + +def test_pull_request_template_explains_policy_trigger_and_changes() -> None: + policy = Policy( + "score-docs-as-code.cleanup", + "Update docs files", + "Replace legacy documentation files.", + BazelCondition(("score_docs_as_code",)), + (), + ) + + body = _pull_request_body( + policy, (Change(Path(".gitignore"), "add '_build'"),), head_oid="a" * 40 + ) + + assert "" in body + assert "## Policy" in body + assert "**`score-docs-as-code.cleanup`**" in body + assert ( + "This repository matches this policy because `MODULE.bazel` declares the required direct Bazel" + in body + ) + assert "`MODULE.bazel` declares the required direct Bazel dependency" in body + assert "- `.gitignore`: add '_build'" in body + assert body.index("## Policy") < body.index("", + "mergeable": "CONFLICTING", + } + ] + ) + return "[]" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(run)) + + pull_request = GitHubCli().find_open_pull_request( + repository="owner/repo", + branches=policy_branches(policy), + policy_id=policy.id, + ) + + assert pull_request is not None + assert pull_request.expected_head_oid is None + assert pull_request.mergeable == "CONFLICTING" + + +def test_pre_existing_user_pull_request_is_not_reused(monkeypatch) -> None: + policy = Policy("example", "Example", None, None, ()) + branch = policy_branches(policy)[0] + + def run(command: list[str]) -> str: + if command[command.index("--head") + 1] == branch: + return json.dumps( + [ + { + "number": 1, + "url": "https://github.example/owner/repo/pull/1", + "body": "A pull request opened by a maintainer.", + "mergeable": "MERGEABLE", + } + ] + ) + return "[]" + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(run)) + + with pytest.raises(CommandError, match="is not owned by policy example"): + GitHubCli().find_open_pull_request( + repository="owner/repo", + branches=policy_branches(policy), + policy_id=policy.id, + ) + + +def test_policy_pull_request_status_includes_latest_merged_pull_request( + monkeypatch, +) -> None: + policy = Policy("example", "Example", None, None, ()) + branch = policy_branches(policy)[0] + + def run(command: list[str]) -> str: + assert command[command.index("--head") + 1] == branch + state = command[command.index("--state") + 1] + if state == "open": + return json.dumps( + [ + { + "number": 3, + "url": "https://github.example/owner/repo/pull/3", + "body": "\n" + "", + } + ] + ) + return json.dumps( + [ + { + "number": 2, + "url": "https://github.example/owner/repo/pull/2", + "body": "\n" + "", + "mergedAt": "2026-01-01T00:00:00Z", + }, + { + "number": 99, + "url": "https://github.example/owner/repo/pull/99", + "body": "a historical PR owned by another tool", + "mergedAt": "2026-02-01T00:00:00Z", + }, + ] + ) + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(run)) + + status = GitHubCli().find_policy_pull_request_status( + repository="owner/repo", + branches=(branch,), + policy_id=policy.id, + ) + + assert status.open is not None + assert status.open.url.endswith("/3") + assert status.merged is not None + assert status.merged.url.endswith("/2") + + +def test_multiple_policy_pull_requests_fail_instead_of_choosing(monkeypatch) -> None: + policy = Policy("current", "Example", None, None, ()) + branch = policy_branches(policy)[0] + body = ( + "\n" + ) + + def run(command: list[str]) -> str: + assert command[command.index("--head") + 1] == branch + return json.dumps( + [ + { + "number": 1, + "url": "https://github.example/owner/repo/pull/1", + "body": body, + }, + { + "number": 2, + "url": "https://github.example/owner/repo/pull/2", + "body": body, + }, + ] + ) + + monkeypatch.setattr(GitHubCli, "_run", staticmethod(run)) + + with pytest.raises( + CommandError, match="multiple open pull requests match policy current" + ): + GitHubCli().find_open_pull_request( + repository="owner/repo", + branches=policy_branches(policy), + policy_id=policy.id, + ) diff --git a/repo_policy_sync/tests/test_policy_and_metrics.py b/repo_policy_sync/tests/test_policy_and_metrics.py new file mode 100644 index 0000000..e2a4808 --- /dev/null +++ b/repo_policy_sync/tests/test_policy_and_metrics.py @@ -0,0 +1,561 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from pathlib import Path + +import pytest + +from repo_policy_sync.errors import PolicyError +from repo_policy_sync.policy import ( + BUNDLED_POLICY_DIRECTORY, + discover_policy_paths, + load_policy, + load_policies, + resolve_policy_names, +) + + +def test_load_policy_rejects_path_outside_repository(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +ensure: + - type: ensure_no_such_file + path: ../outside +""" + ) + with pytest.raises(PolicyError, match="repository-relative"): + load_policy(policy_path) + + +def test_load_policy_rejects_unknown_fields(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +ensure: + - type: ensure_no_such_file + path: obsolete-file +surprise: value +""" + ) + with pytest.raises(PolicyError, match="unexpected fields"): + load_policy(policy_path) + + +def test_load_policy_rejects_malformed_yaml(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text("title: [unterminated\n", encoding="utf-8") + + with pytest.raises(PolicyError, match="invalid YAML"): + load_policy(policy_path) + + +def test_load_policy_rejects_non_utf8_input(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_bytes(b"title: Example\nensure: [\xff]\n") + + with pytest.raises(PolicyError, match="decode policy.*UTF-8"): + load_policy(policy_path) + + +def test_load_policy_rejects_non_utf8_synchronize_file_source(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + (policy_path.parent / "asset.txt").write_bytes(b"\xff") + policy_path.write_text( + """title: Example +ensure: + - type: synchronize_file + path: target.txt + source: asset.txt +""", + encoding="utf-8", + ) + + with pytest.raises(PolicyError, match="source must be UTF-8"): + load_policy(policy_path) + + +def test_load_policy_rejects_unsupported_operation(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +ensure: + - type: unsupported_operation + path: example.txt +""", + encoding="utf-8", + ) + + with pytest.raises(PolicyError, match="unsupported ensure type"): + load_policy(policy_path) + + +@pytest.mark.parametrize( + ("operation", "message"), + [ + ( + "- type: ensure_line\n path: example.txt\n line: 1", + "line must be a non-empty string", + ), + ( + "- type: ensure_minimum_version\n" + " path: .bazelversion\n" + " minimum_version: '8.6'", + "minimum_version must be a numeric major.minor.patch version", + ), + ( + "- type: ensure_no_such_file\n path: .", + "path must be a non-empty repository-relative path", + ), + ( + "- type: ensure_bazel_dependency\n" + " dockerfile: .devcontainer/Dockerfile\n" + " module_file: MODULE.bazel\n" + " image: ' '\n" + " module_name: score_devcontainer", + "image must be a non-empty string", + ), + ( + "- type: migrate_devcontainer_json\n" + " sources: ['.']\n" + " destination: .devcontainer/devcontainer.json\n" + " dockerfile: .devcontainer/Dockerfile\n" + " image: ghcr.io/eclipse-score/devcontainer", + "path must be a non-empty repository-relative path", + ), + ( + "- type: replace_regex\n" + " path: example.txt\n" + " pattern: '['\n" + " replacement: current", + "invalid replace_regex pattern or replacement", + ), + ( + "- type: synchronize_devcontainer_version\n" + " dockerfile: .devcontainer/Dockerfile\n" + " module_file: MODULE.bazel\n" + " image: ghcr.io/eclipse-score/devcontainer\n" + " module_name: ''", + "module_name must be a non-empty string", + ), + ( + "- type: synchronize_bazel_dependencies\n" + " module_file: MODULE.bazel\n" + " dependencies: []", + "dependencies must be a non-empty list", + ), + ( + "- type: synchronize_file\n path: workflow.yml\n source: missing.yml", + "synchronize_file source must be an existing file", + ), + ], +) +def test_load_policy_reports_invalid_values_for_each_operation( + tmp_path: Path, operation: str, message: str +) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + f"title: Example\nensure:\n{operation}\n", + encoding="utf-8", + ) + + with pytest.raises(PolicyError, match=message): + load_policy(policy_path) + + +def test_load_policy_rejects_invalid_file_condition_regex(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +when: + file_contains: + path: README.md + pattern: "[" +ensure: + - type: ensure_no_such_file + path: obsolete-file +""", + encoding="utf-8", + ) + + with pytest.raises(PolicyError, match="invalid file_contains pattern"): + load_policy(policy_path) + + +def test_load_policy_rejects_invalid_replace_regex_replacement( + tmp_path: Path, +) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +ensure: + - type: replace_regex + path: example.txt + pattern: legacy + replacement: '\\1' +""", + encoding="utf-8", + ) + + with pytest.raises( + PolicyError, match="invalid replace_regex pattern or replacement" + ): + load_policy(policy_path) + + +def test_load_policy_rejects_policy_scoped_labels(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +labels: [automation] +ensure: + - type: ensure_no_such_file + path: obsolete-file +""" + ) + + with pytest.raises(PolicyError, match="unexpected fields"): + load_policy(policy_path) + + +def test_load_policy_accepts_operation_rationale(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +ensure: + - type: ensure_no_such_file + path: obsolete-file + rationale: This file is obsolete. +""" + ) + + policy = load_policy(policy_path) + + assert policy.ensure[0].rationale == "This file is obsolete." + + +def test_load_policy_rejects_non_string_operation_rationale(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +ensure: + - type: ensure_no_such_file + path: obsolete-file + rationale: [not, a, string] +""" + ) + + with pytest.raises(PolicyError, match="rationale must be a non-empty string"): + load_policy(policy_path) + + +@pytest.mark.parametrize( + ("content", "message"), + [ + ( + """title: Example +description: ' ' +ensure: + - type: ensure_no_such_file + path: obsolete +""", + "description must be a non-empty string", + ), + ( + """title: Example +when: + bazel: + direct_module_dependencies: [' '] +ensure: + - type: ensure_no_such_file + path: obsolete +""", + "when.bazel.direct_module_dependencies must be a list", + ), + ( + """title: Example +ensure: + - type: ensure_no_such_file + path: obsolete +after_apply: + - command: ['bazel', ' '] + when_file_exists: lock + description: Run +""", + "after_apply command must be a non-empty list of strings", + ), + ], +) +def test_load_policy_rejects_whitespace_only_values( + tmp_path: Path, content: str, message: str +) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text(content, encoding="utf-8") + + with pytest.raises(PolicyError, match=message): + load_policy(policy_path) + + +def test_discover_policies_uses_deterministic_directory_order(tmp_path: Path) -> None: + for name in ("z-policy", "a-policy"): + policy_path = tmp_path / name / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + f"title: {name}\nensure:\n - type: ensure_no_such_file\n path: {name}\n" + ) + + paths = discover_policy_paths(tmp_path) + + assert [path.parent.name for path in paths] == ["a-policy", "z-policy"] + assert [policy.id for policy in load_policies(paths)] == ["a-policy", "z-policy"] + + +def test_load_policies_accepts_an_explicit_empty_selection() -> None: + assert load_policies(()) == () + + +def test_load_policies_rejects_ids_that_collide_on_policy_branches( + tmp_path: Path, +) -> None: + for name in ("foo_bar", "foo-bar"): + policy_path = tmp_path / name / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + f"title: {name}\nensure:\n - type: ensure_no_such_file\n path: {name}\n", + encoding="utf-8", + ) + + with pytest.raises(PolicyError, match="map to the same policy branch slug"): + load_policies(discover_policy_paths(tmp_path)) + + +def test_resolve_policy_names_uses_bundled_policy_directory_names() -> None: + paths = resolve_policy_names( + ("minimal-bazel-module-declaration", "minimum-bazel-version"), + BUNDLED_POLICY_DIRECTORY, + ) + + assert paths == ( + BUNDLED_POLICY_DIRECTORY / "minimal-bazel-module-declaration" / "policy.yml", + BUNDLED_POLICY_DIRECTORY / "minimum-bazel-version" / "policy.yml", + ) + + +def test_resolve_policy_names_rejects_legacy_ids() -> None: + with pytest.raises(PolicyError, match="unknown policy name"): + resolve_policy_names(("module-one-line",), BUNDLED_POLICY_DIRECTORY) + + +def test_resolve_policy_names_uses_custom_policy_directory(tmp_path: Path) -> None: + policy_path = tmp_path / "etas-standard" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: ETAS standard +ensure: + - type: ensure_no_such_file + path: obsolete-file +""" + ) + + assert resolve_policy_names(("etas-standard",), tmp_path) == (policy_path,) + + +def test_resolve_policy_names_combines_policy_directories(tmp_path: Path) -> None: + paths = [] + for directory_name, policy_name in ( + ("etas", "etas-standard"), + ("score", "minimum-bazel-version"), + ): + policy_path = tmp_path / directory_name / policy_name / "policy.yml" + policy_path.parent.mkdir(parents=True) + policy_path.write_text( + f"""title: {policy_name} +ensure: + - type: ensure_no_such_file + path: obsolete-file +""" + ) + paths.append(policy_path) + + assert resolve_policy_names( + ("etas-standard", "minimum-bazel-version"), + tuple((tmp_path / "etas", tmp_path / "score")), + ) == tuple(paths) + + +def test_load_policy_rejects_legacy_identity_field(tmp_path: Path) -> None: + policy_path = tmp_path / "current" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """legacy_ids: [old] +title: Example +ensure: + - type: ensure_no_such_file + path: obsolete-file +""" + ) + + with pytest.raises(PolicyError, match="unexpected fields.*legacy_ids"): + load_policy(policy_path) + + +def test_load_policy_rejects_inline_id(tmp_path: Path) -> None: + policy_path = tmp_path / "current" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """id: different +title: Example +ensure: + - type: ensure_no_such_file + path: obsolete-file +""" + ) + + with pytest.raises(PolicyError, match="unexpected fields.*id"): + load_policy(policy_path) + + +def test_resolve_policy_names_rejects_unknown_policy_name(tmp_path: Path) -> None: + known_policy = tmp_path / "known" / "policy.yml" + known_policy.parent.mkdir() + known_policy.write_text( + """title: Known +ensure: + - type: ensure_no_such_file + path: obsolete-file +""" + ) + + with pytest.raises(PolicyError, match="unknown policy name"): + resolve_policy_names(("not-a-policy",), tmp_path) + + +def test_load_policy_accepts_conditional_after_apply_command(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +ensure: + - type: ensure_no_such_file + path: obsolete-file +after_apply: + - command: [bazel, mod, deps] + when_file_exists: MODULE.bazel.lock + description: Regenerate the lock file. +""" + ) + + policy = load_policy(policy_path) + + assert policy.after_apply[0].command == ("bazel", "mod", "deps") + assert policy.after_apply[0].when_file_exists == Path("MODULE.bazel.lock") + + +def test_load_policy_accepts_file_content_condition_and_changed_path_guard( + tmp_path: Path, +) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +when: + file_contains: + path: .devcontainer/Dockerfile + pattern: '^FROM example:' +ensure: + - type: ensure_no_such_file + path: obsolete-file +after_apply: + - command: [bazel, mod, deps] + when_file_exists: MODULE.bazel.lock + when_path_changed: MODULE.bazel + description: Regenerate the lock file. +""" + ) + + policy = load_policy(policy_path) + + assert policy.file_contains_condition is not None + assert policy.file_contains_condition.path == Path(".devcontainer/Dockerfile") + assert policy.after_apply[0].when_path_changed == Path("MODULE.bazel") + + +def test_load_policy_accepts_file_exists_condition(tmp_path: Path) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +when: + file_exists: MODULE.bazel +ensure: + - type: ensure_no_such_file + path: obsolete-file +""" + ) + + policy = load_policy(policy_path) + + assert policy.file_exists_condition is not None + assert policy.file_exists_condition.path == Path("MODULE.bazel") + + +def test_load_policy_accepts_any_direct_bazel_dependency_and_glob_condition( + tmp_path: Path, +) -> None: + policy_path = tmp_path / "example" / "policy.yml" + policy_path.parent.mkdir() + policy_path.write_text( + """title: Example +when: + bazel: + direct_module_dependencies: [score_platform] + any_direct_module_dependencies: [score_process, score_process_description] + any_direct_module_conditions: + - score_process_description < 2.1.1 + file_contains_any: + - path: '**/BUILD' + pattern: score_process +ensure: + - type: ensure_no_such_file + path: obsolete-file +""" + ) + + policy = load_policy(policy_path) + + assert policy.bazel_condition is not None + assert policy.bazel_condition.direct_module_dependencies == ("score_platform",) + assert policy.bazel_condition.any_direct_module_dependencies == ( + "score_process", + "score_process_description", + ) + assert policy.bazel_condition.any_direct_module_conditions[0].module_name == ( + "score_process_description" + ) + assert policy.bazel_condition.any_direct_module_conditions[0].operator == "<" + assert policy.bazel_condition.any_direct_module_conditions[0].version == (2, 1, 1) + assert policy.file_contains_any_condition is not None + assert policy.file_contains_any_condition.conditions[0].path == Path("**/BUILD") diff --git a/repo_policy_sync/tests/test_policy_fixtures.py b/repo_policy_sync/tests/test_policy_fixtures.py new file mode 100644 index 0000000..b9e4bd0 --- /dev/null +++ b/repo_policy_sync/tests/test_policy_fixtures.py @@ -0,0 +1,50 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Executable examples colocated with the policies they specify.""" + +from pathlib import Path +from shutil import copytree + +from repo_policy_sync.engine import apply_policy +from repo_policy_sync.policy import BUNDLED_POLICY_DIRECTORY, load_policy + + +def test_policy_examples_apply_as_documented(tmp_path: Path) -> None: + for policy_directory in sorted( + path for path in BUNDLED_POLICY_DIRECTORY.iterdir() if path.is_dir() + ): + policy = load_policy(policy_directory / "policy.yml") + for case in sorted( + path for path in policy_directory.iterdir() if path.is_dir() + ): + actual = tmp_path / policy_directory.name / case.name + copytree(case / "before", actual) + + apply_policy(actual, policy, organization="eclipse-score") + + assert _tree(actual) == _tree(case / "after"), case + compliant = tmp_path / policy_directory.name / f"{case.name}-compliant" + copytree(case / "after", compliant) + assert ( + apply_policy(compliant, policy, organization="eclipse-score").changes + == () + ), case + + +def _tree(root: Path) -> dict[Path, str]: + return { + path.relative_to(root): path.read_text(encoding="utf-8") + for path in sorted(root.rglob("*")) + if path.is_file() + } diff --git a/repo_policy_sync/tests/test_reporting.py b/repo_policy_sync/tests/test_reporting.py new file mode 100644 index 0000000..8b36dd9 --- /dev/null +++ b/repo_policy_sync/tests/test_reporting.py @@ -0,0 +1,287 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from pathlib import Path + +from repo_policy_sync.models import Change +from repo_policy_sync.reporting import render_json, render_markdown, render_table +from repo_policy_sync.runner import RepositoryOutcome, RunReport, RunSummary + + +def _report() -> RunReport: + return RunReport( + summary=RunSummary( + repositories=1, + synchronized=1, + sync_failures=0, + skipped=0, + evaluations=1, + compliant=0, + drifted=1, + not_applicable=0, + evaluation_failures=0, + pull_requests_created=0, + pull_requests_updated=0, + pull_requests_open=0, + pull_requests_recreated=0, + ), + outcomes=( + RepositoryOutcome( + repository="example", + policy_id="example-policy", + when="yes (live)", + status="changes-required", + changes=( + Change( + Path(".gitignore"), "add '_build'", "Avoid generated files." + ), + ), + ), + ), + ) + + +def test_render_table_includes_each_outcome_and_summary() -> None: + output = render_table(_report()) + + assert "┌" in output + assert "┼" in output + assert "└" in output + assert "example-policy" in output + assert "🔴" in output + assert "Repositories" in output + assert "Policy evaluations" in output + assert "⚪" in output + assert "100.0%" in output + assert "-------|" not in output + assert "When" not in output + + +def test_render_table_groups_failure_causes() -> None: + report = RunReport( + summary=RunSummary( + repositories=2, + synchronized=2, + sync_failures=0, + skipped=0, + evaluations=2, + compliant=0, + drifted=0, + not_applicable=0, + evaluation_failures=2, + pull_requests_created=0, + pull_requests_updated=0, + pull_requests_open=0, + pull_requests_recreated=0, + ), + outcomes=( + RepositoryOutcome( + "first", "example", "unknown", "error", error="Bazel failed" + ), + RepositoryOutcome( + "second", "example", "unknown", "error", error="Bazel failed" + ), + ), + ) + + output = render_table(report) + + assert "⚠ failed" in output + assert "100.0%" in output + assert "2" in output + assert "Bazel failed" in output + assert "example/first" in output + assert "example/second" in output + + +def test_render_table_wraps_long_values_for_terminal_width(monkeypatch) -> None: + monkeypatch.setenv("COLUMNS", "80") + report = RunReport( + summary=RunSummary( + repositories=1, + synchronized=1, + sync_failures=0, + skipped=0, + evaluations=1, + compliant=0, + drifted=1, + not_applicable=0, + evaluation_failures=0, + pull_requests_created=0, + pull_requests_updated=0, + pull_requests_open=0, + pull_requests_recreated=0, + ), + outcomes=( + RepositoryOutcome( + repository="a-repository-with-a-deliberately-long-name", + policy_id="a-policy-with-a-deliberately-long-name", + when="yes (live)", + status="changes-required", + changes=( + Change( + Path(".github/workflows/a-very-long-file-name.yml"), + "replace a value with a much longer explanation", + ), + ), + ), + ), + ) + + output = render_table(report) + + assert "a-policy-with" in output + assert "a-policy-with-a-del" in output + assert "iberately-long-name" in output + assert "a-repository-with" in output + assert "a-repository-with-a-de" in output + assert "liberately-long-name" in output + assert "with a much" in output + assert "longer" in output + assert "│" in output + + +def test_render_table_uses_red_changes_required_marker() -> None: + output = render_table(_report()) + + assert "🔴" in output + + +def test_render_json_is_machine_readable_and_versioned() -> None: + output = render_json(_report()) + + assert '"schema_version": 2' in output + assert '"drifted": 1' in output + assert '"path": ".gitignore"' in output + + +def test_render_markdown_distinguishes_compliance_and_policy_pr_status() -> None: + report = RunReport( + summary=RunSummary( + repositories=4, + synchronized=4, + sync_failures=0, + skipped=0, + evaluations=4, + compliant=3, + drifted=1, + not_applicable=0, + evaluation_failures=0, + pull_requests_created=0, + pull_requests_updated=0, + pull_requests_open=0, + pull_requests_recreated=0, + pull_requests_closed=1, + ), + outcomes=( + RepositoryOutcome( + "compliant", + "example", + "yes (live)", + "compliant", + policy_pr_status="none", + ), + RepositoryOutcome( + "open-pr", + "example", + "yes (live)", + "changes-required", + pull_request_url="https://github.example/owner/open-pr/pull/1", + policy_pr_status="open", + ), + RepositoryOutcome( + "merged-pr", + "example", + "yes (live)", + "compliant", + pull_request_url="https://github.example/owner/merged-pr/pull/2", + policy_pr_status="merged", + ), + RepositoryOutcome( + "closed-pr", + "example", + "yes (live)", + "pull-request-closed", + pull_request_url="https://github.example/owner/closed-pr/pull/3", + policy_pr_status="closed", + ), + ), + ) + + output = render_markdown(report) + + assert "# Repository policy compliance" in output + assert "| Repository | example |" in output + assert "| compliant | ✅ |" in output + assert ( + "| open-pr | ❌ [![Open PR](https://img.shields.io/badge/-Open-2ea043" + "?style=flat&logo=github&logoColor=white)]" + "(https://github.example/owner/open-pr/pull/1) |" + ) in output + assert ( + "| merged-pr | ✅ [![Merged PR](https://img.shields.io/badge/-Merged-8250df" + "?style=flat&logo=github&logoColor=white)]" + "(https://github.example/owner/merged-pr/pull/2) |" + ) in output + assert ( + "| closed-pr | ✅ [![Closed PR](https://img.shields.io/badge/-Closed-6e7781" + "?style=flat&logo=github&logoColor=white)]" + "(https://github.example/owner/closed-pr/pull/3) |" + ) in output + assert "`✅ 1 closed`" in output + + +def test_render_markdown_uses_policies_as_matrix_columns_and_keeps_details_compact() -> ( + None +): + report = RunReport( + summary=RunSummary( + repositories=2, + synchronized=2, + sync_failures=0, + skipped=0, + evaluations=4, + compliant=1, + drifted=1, + not_applicable=1, + evaluation_failures=1, + pull_requests_created=0, + pull_requests_updated=0, + pull_requests_open=0, + pull_requests_recreated=0, + ), + outcomes=( + RepositoryOutcome( + "first", + "policy-a", + "yes (live)", + "changes-required", + changes=(Change(Path(".gitignore"), "add generated files", None),), + ), + RepositoryOutcome("first", "policy-b", "no (live)", "not-applicable"), + RepositoryOutcome("second", "policy-a", "yes (live)", "compliant"), + RepositoryOutcome( + "second", "policy-b", "unknown", "error", error="Bazel failed" + ), + ), + ) + + output = render_markdown(report) + + assert "| Repository | policy-a | policy-b |" in output + assert "| first | ❌ | N/A |" in output + assert "| second | ✅ | ⚠️ |" in output + assert "Details (2)" in output + assert ".gitignore: add generated files" in output + assert "Bazel failed" in output + assert "| Repository | Policy | Status |" not in output diff --git a/repo_policy_sync/tests/test_runner.py b/repo_policy_sync/tests/test_runner.py new file mode 100644 index 0000000..e3ac920 --- /dev/null +++ b/repo_policy_sync/tests/test_runner.py @@ -0,0 +1,1089 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from __future__ import annotations + +import shutil +import threading +from pathlib import Path + +import pytest + +from repo_policy_sync import runner +from repo_policy_sync.errors import RepoPolicySyncError +from repo_policy_sync.github import ( + CommitResult, + PolicyPullRequestStatus, + PullRequest, + _pull_request_body, +) +from repo_policy_sync.models import ( + BazelCondition, + EnsureLine, + EnsureNoSuchFile, + Evaluation, + Policy, + Repository, +) +from repo_policy_sync.runner import _run_repository, run_policies + + +class FakeRepositoryClient: + def __init__(self, source: Path, repositories: tuple[Repository, ...]) -> None: + self.source = source + self.repositories = repositories + self.authenticated = False + self.cloned: list[str] = [] + + def ensure_authenticated(self) -> None: + self.authenticated = True + + def list_repositories(self, *, org: str) -> tuple[Repository, ...]: + return self.repositories + + def sync_default_branch( + self, *, repository: str, branch: str, destination: Path + ) -> None: + self.cloned.append(repository) + shutil.copytree(self.source, destination) + + def restore_synced_default_branch(self, **_: object) -> None: + pass + + def find_open_pull_request(self, **_: object) -> None: + return None + + def switch_to_policy_branch(self, **_: object) -> None: + raise AssertionError("plan mode must not create a branch") + + def commit_and_push(self, **_: object) -> None: + raise AssertionError("plan mode must not push") + + def create_pull_request(self, **_: object) -> None: + raise AssertionError("plan mode must not create a pull request") + + +class RoundTripClient: + def __init__(self) -> None: + self.pull_request: PullRequest | None = None + self.branch_snapshot: dict[Path, bytes] | None = None + self.commit_calls = 0 + self.create_calls = 0 + + def find_open_pull_request(self, **_: object) -> PullRequest | None: + return self.pull_request + + def switch_to_policy_branch( + self, *, checkout: Path, exists_remotely: bool, **_: object + ) -> None: + if exists_remotely: + assert self.branch_snapshot is not None + _restore_snapshot(checkout, self.branch_snapshot) + + def verify_policy_branch_head(self, **_: object) -> None: + pass + + def commit_and_push(self, *, checkout: Path, **_: object) -> CommitResult: + self.commit_calls += 1 + self.branch_snapshot = _snapshot(checkout) + return CommitResult("b" * 40) + + def create_pull_request( + self, + *, + repository: str, + branch: str, + policy: Policy, + changes: tuple, + head_oid: str, + **_: object, + ) -> PullRequest: + self.create_calls += 1 + self.pull_request = PullRequest( + number=1, + 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), + mergeable="MERGEABLE", + ) + return self.pull_request + + +def _snapshot(root: Path) -> dict[Path, bytes]: + return { + path.relative_to(root): path.read_bytes() + for path in root.rglob("*") + if path.is_file() + } + + +def _restore_snapshot(root: Path, snapshot: dict[Path, bytes]) -> None: + for child in root.iterdir(): + if child.is_dir() and not child.is_symlink(): + shutil.rmtree(child) + else: + child.unlink() + for relative, contents in snapshot.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(contents) + + +class PolicyStatusClient(FakeRepositoryClient): + def __init__( + self, + source: Path, + repositories: tuple[Repository, ...], + status: PolicyPullRequestStatus, + ) -> None: + super().__init__(source, repositories) + self.status = status + + def find_policy_pull_request_status(self, **_: object) -> PolicyPullRequestStatus: + return self.status + + +class CompliantRunClient(FakeRepositoryClient): + def __init__(self, source: Path, repositories: tuple[Repository, ...]) -> None: + super().__init__(source, repositories) + self.pull_request = PullRequest( + 1, + "https://github.example/eclipse-score/candidate/pull/1", + expected_head_oid="a" * 40, + branch="repo-policy-sync/example", + ) + self.closed = False + + def find_open_pull_request(self, **_: object) -> PullRequest: + return self.pull_request + + def verify_policy_branch_head(self, **_: object) -> None: + pass + + def close_pull_request(self, **_: object) -> None: + self.closed = True + + +def test_runner_clones_all_repositories(tmp_path: Path, capsys) -> None: + repository = tmp_path / "repository" + repository.mkdir() + (repository / "MODULE.bazel").write_text('bazel_dep(name = "score_docs_as_code")\n') + policy = Policy( + id="example", + title="Example", + description=None, + bazel_condition=BazelCondition(("score_docs_as_code",)), + ensure=(EnsureLine(Path(".gitignore"), "_build", ()),), + ) + repositories = ( + Repository("candidate", "main"), + Repository("excluded", "main"), + ) + client = FakeRepositoryClient(repository, repositories) + + report = run_policies( + client=client, + org="eclipse-score", + policies=(policy,), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=False, + ) + + assert client.authenticated + assert client.cloned == ["eclipse-score/candidate", "eclipse-score/excluded"] + assert report.summary.repositories == 2 + assert report.summary.evaluations == 2 + assert report.summary.drifted == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "Synchronizing 2 checkout(s)" in captured.err + assert [outcome.repository for outcome in report.outcomes] == [ + "candidate", + "excluded", + ] + assert all(outcome.status == "changes-required" for outcome in report.outcomes) + + +def test_plan_apply_and_repeat_reuses_the_owned_policy_branch(tmp_path: Path) -> None: + default = tmp_path / "default" + default.mkdir() + checkout = tmp_path / "checkout" + shutil.copytree(default, checkout) + policy = Policy( + id="example", + title="Example", + description=None, + bazel_condition=None, + ensure=(EnsureLine(Path("required.txt"), "yes", ()),), + ) + client = RoundTripClient() + + plan = _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=False, + ) + + assert plan.status == "changes-required" + assert client.commit_calls == 0 + assert client.create_calls == 0 + assert not (checkout / "required.txt").exists() + + first_apply = _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + ) + + assert first_apply.status == "pull-request-created" + assert client.commit_calls == 1 + assert client.create_calls == 1 + + shutil.rmtree(checkout) + shutil.copytree(default, checkout) + second_apply = _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + ) + + assert second_apply.status == "pull-request-open" + assert client.commit_calls == 1 + assert client.create_calls == 1 + + +def test_runner_adds_policy_pull_request_status_for_markdown_reports( + tmp_path: Path, +) -> None: + source = tmp_path / "repository" + source.mkdir() + policy = Policy( + "example", "Example", None, None, (EnsureLine(Path("required.txt"), "yes", ()),) + ) + client = PolicyStatusClient( + source, + (Repository("candidate", "main"),), + PolicyPullRequestStatus( + merged=PullRequest( + 7, + "https://github.example/owner/candidate/pull/7", + merged_at="2026-01-01", + ) + ), + ) + + report = run_policies( + client=client, + org="eclipse-score", + policies=(policy,), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=False, + sync_workers=1, + policy_workers=1, + include_pull_request_status=True, + ) + + assert report.outcomes[0].policy_pr_status == "merged" + assert report.outcomes[0].pull_request_url.endswith("/7") + + +def test_runner_counts_closed_pull_requests_as_compliant(tmp_path: Path) -> None: + source = tmp_path / "repository" + source.mkdir() + (source / "required.txt").write_text("yes\n") + policy = Policy( + "example", "Example", None, None, (EnsureLine(Path("required.txt"), "yes", ()),) + ) + client = CompliantRunClient(source, (Repository("candidate", "main"),)) + + report = run_policies( + client=client, + org="eclipse-score", + policies=(policy,), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=True, + sync_workers=1, + policy_workers=1, + ) + + assert report.summary.compliant == 1 + assert report.summary.drifted == 0 + assert report.summary.pull_requests_closed == 1 + assert report.outcomes[0].status == "pull-request-closed" + assert client.closed + + +def test_runner_syncs_a_repository_once_for_multiple_policies(tmp_path: Path) -> None: + repository = tmp_path / "repository" + repository.mkdir() + first = Policy( + id="first", + title="First", + description=None, + bazel_condition=None, + ensure=(EnsureLine(Path("first.txt"), "first", ()),), + ) + second = Policy( + id="second", + title="Second", + description=None, + bazel_condition=None, + ensure=(EnsureLine(Path("second.txt"), "second", ()),), + ) + client = FakeRepositoryClient(repository, (Repository("candidate", "main"),)) + + report = run_policies( + client=client, + org="eclipse-score", + policies=(first, second), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=False, + sync_workers=2, + ) + + assert client.cloned == ["eclipse-score/candidate"] + assert report.summary.repositories == 1 + assert report.summary.evaluations == 2 + assert report.summary.drifted == 2 + + +def test_runner_excludes_archived_repositories(tmp_path: Path) -> None: + source = tmp_path / "repository" + source.mkdir() + policy = Policy( + id="example", + title="Example", + description=None, + bazel_condition=None, + ensure=(EnsureLine(Path(".gitignore"), "_build", ()),), + ) + client = FakeRepositoryClient( + source, + ( + Repository("active", "main"), + Repository("archived", "main", archived=True), + Repository("without-default", None), + ), + ) + + report = run_policies( + client=client, + org="eclipse-score", + policies=(policy,), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=False, + sync_workers=1, + policy_workers=1, + ) + + assert client.cloned == ["eclipse-score/active"] + assert report.summary.repositories == 2 + assert report.summary.skipped == 1 + assert [(outcome.repository, outcome.status) for outcome in report.outcomes] == [ + ("active", "changes-required"), + ("without-default", "skipped"), + ] + + +def test_runner_propagates_authentication_failures(tmp_path: Path) -> None: + source = tmp_path / "repository" + source.mkdir() + + class AuthenticationFailureClient(FakeRepositoryClient): + def ensure_authenticated(self) -> None: + raise RepoPolicySyncError("authentication failed") + + client = AuthenticationFailureClient(source, (Repository("active", "main"),)) + + with pytest.raises(RepoPolicySyncError, match="authentication failed"): + run_policies( + client=client, + org="eclipse-score", + policies=(), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=False, + ) + + +def test_runner_counts_a_sync_failure_once_per_repository(tmp_path: Path) -> None: + source = tmp_path / "repository" + source.mkdir() + policies = ( + Policy( + "first", "First", None, None, (EnsureLine(Path("first.txt"), "first", ()),) + ), + Policy( + "second", + "Second", + None, + None, + (EnsureLine(Path("second.txt"), "second", ()),), + ), + ) + client = SyncFailureClient(source, (Repository("candidate", "main"),)) + + report = run_policies( + client=client, + org="eclipse-score", + policies=policies, + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=False, + sync_workers=1, + policy_workers=1, + ) + + assert report.summary.repositories == 1 + assert report.summary.synchronized == 0 + assert report.summary.sync_failures == 1 + assert report.summary.evaluations == 0 + assert [outcome.status for outcome in report.outcomes] == [ + "sync-error", + "sync-error", + ] + + +def test_runner_reports_checkout_os_errors_without_a_traceback(tmp_path: Path) -> None: + source = tmp_path / "repository" + source.mkdir() + policy = Policy( + "example", + "Example", + None, + None, + (EnsureLine(Path("required.txt"), "yes", ()),), + ) + client = SyncOSErrorClient(source, (Repository("candidate", "main"),)) + + report = run_policies( + client=client, + org="eclipse-score", + policies=(policy,), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=False, + sync_workers=1, + policy_workers=1, + ) + + assert report.summary.sync_failures == 1 + assert report.outcomes[0].status == "sync-error" + assert report.outcomes[0].error == "checkout cache is not accessible" + + +def test_runner_reports_policy_file_io_failures_without_aborting_other_evaluations( + tmp_path: Path, monkeypatch +) -> None: + source = tmp_path / "repository" + source.mkdir() + policy = Policy( + "example", "Example", None, None, (EnsureLine(Path("required.txt"), "yes", ()),) + ) + client = FakeRepositoryClient( + source, + (Repository("first", "main"), Repository("second", "main")), + ) + calls = 0 + + def evaluate(*_: object, **__: object): + nonlocal calls + calls += 1 + if calls == 1: + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + return Evaluation(applies=True, changes=()) + + monkeypatch.setattr(runner, "evaluate_policy", evaluate) + + report = run_policies( + client=client, + org="eclipse-score", + policies=(policy,), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=False, + sync_workers=1, + policy_workers=1, + ) + + assert report.summary.evaluation_failures == 1 + assert report.summary.compliant == 1 + assert [outcome.status for outcome in report.outcomes] == ["error", "compliant"] + assert ( + report.outcomes[0].error + == "policy execution failed: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte" + ) + + +def test_runner_redacts_raw_policy_execution_errors( + tmp_path: Path, monkeypatch +) -> None: + source = tmp_path / "repository" + source.mkdir() + policy = Policy( + "example", "Example", None, None, (EnsureLine(Path("required.txt"), "yes", ()),) + ) + client = FakeRepositoryClient(source, (Repository("candidate", "main"),)) + + def evaluate(*_: object, **__: object): + raise UnicodeError("authorization: Bearer ghp_secret_value_12345") + + monkeypatch.setattr(runner, "evaluate_policy", evaluate) + + report = run_policies( + client=client, + org="eclipse-score", + policies=(policy,), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=False, + sync_workers=1, + policy_workers=1, + ) + + assert report.outcomes[0].error is not None + assert "ghp_secret_value_12345" not in report.outcomes[0].error + assert "[REDACTED]" in report.outcomes[0].error + + +def test_runner_processes_one_policy_across_repositories_in_parallel( + tmp_path: Path, monkeypatch +) -> None: + source = tmp_path / "repository" + source.mkdir() + policy = Policy( + id="example", + title="Example", + description=None, + bazel_condition=None, + ensure=(EnsureLine(Path(".gitignore"), "_build", ()),), + ) + client = FakeRepositoryClient( + source, + (Repository("first", "main"), Repository("second", "main")), + ) + barrier = threading.Barrier(2) + + def run_in_parallel(**kwargs: object) -> runner.RepositoryOutcome: + barrier.wait(timeout=2) + return runner.RepositoryOutcome( + repository=str(kwargs["repository"]), + policy_id=policy.id, + when="yes (live)", + status="compliant", + ) + + monkeypatch.setattr(runner, "_run_repository", run_in_parallel) + + report = run_policies( + client=client, + org="eclipse-score", + policies=(policy,), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + apply=False, + sync_workers=2, + policy_workers=2, + ) + + assert report.summary.compliant == 2 + + +class SyncFailureClient(FakeRepositoryClient): + def sync_default_branch( + self, *, repository: str, branch: str, destination: Path + ) -> None: + self.cloned.append(repository) + raise RepoPolicySyncError("checkout failed") + + +class SyncOSErrorClient(FakeRepositoryClient): + def sync_default_branch( + self, *, repository: str, branch: str, destination: Path + ) -> None: + raise PermissionError("checkout cache is not accessible") + + +def test_existing_pull_request_is_closed_with_failure_details(tmp_path: Path) -> None: + checkout = tmp_path / "repository" + (checkout / "obsolete").mkdir(parents=True) + policy = Policy( + id="example", + title="Example", + description=None, + bazel_condition=None, + ensure=(EnsureNoSuchFile(Path("obsolete")),), + ) + client = ExistingPullRequestFailureClient() + + with pytest.raises(RepoPolicySyncError, match="refusing to remove directory"): + _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + ) + + assert client.failure is not None + assert "refusing to remove directory" in client.failure + assert client.closed + + +def test_pre_commit_failure_can_create_a_dirty_draft_pull_request( + tmp_path: Path, +) -> None: + checkout = tmp_path / "repository" + checkout.mkdir() + policy = Policy( + "example", "Example", None, None, (EnsureLine(Path("required.txt"), "yes", ()),) + ) + client = DirtyPullRequestClient() + + outcome = _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + allow_dirty_pr=True, + ) + + assert outcome.status == "pull-request-created" + assert client.created_draft + assert not client.marked_draft + assert client.failure == "pre-commit found issues" + + +class DirtyPullRequestClient: + def __init__(self) -> None: + self.created_draft = False + self.marked_draft = False + self.failure: str | None = None + self.pull_request = PullRequest( + 1, "https://github.example/eclipse-score/candidate/pull/1" + ) + + def find_open_pull_request(self, **_: object) -> None: + return None + + def switch_to_policy_branch(self, **_: object) -> None: + pass + + def commit_and_push(self, **_: object) -> CommitResult: + return CommitResult("b" * 40, "pre-commit found issues") + + def create_pull_request(self, *, draft: bool = False, **_: object) -> PullRequest: + self.created_draft = draft + return self.pull_request + + def mark_pull_request_draft(self, **_: object) -> None: + self.marked_draft = True + + def comment_on_pull_request(self, *, failure: str, **_: object) -> None: + self.failure = failure + + +class ExistingPullRequestFailureClient: + pull_request = PullRequest( + 1, "https://github.example/eclipse-score/candidate/pull/1", "a" * 40 + ) + + def __init__(self) -> None: + self.failure: str | None = None + self.closed = False + + def find_open_pull_request(self, **_: object) -> PullRequest: + return self.pull_request + + def switch_to_policy_branch(self, **_: object) -> None: + pass + + def verify_policy_branch_head(self, **_: object) -> None: + pass + + def commit_and_push(self, **_: object) -> str: + raise AssertionError("a failed policy must not be committed") + + def update_pull_request(self, *, failure: str | None = None, **_: object) -> None: + self.failure = failure + + def close_pull_request(self, **_: object) -> None: + self.closed = True + + +class CompliantPullRequestClient: + def __init__(self, *, verification_failure: RepoPolicySyncError | None = None): + self.pull_request = PullRequest( + 1, + "https://github.example/eclipse-score/candidate/pull/1", + expected_head_oid="a" * 40, + branch="repo-policy-sync/example", + ) + self.verification_failure = verification_failure + self.verified = False + self.closed = False + + def find_open_pull_request(self, **_: object) -> PullRequest: + return self.pull_request + + def verify_policy_branch_head(self, **_: object) -> None: + self.verified = True + if self.verification_failure is not None: + raise self.verification_failure + + def close_pull_request(self, **_: object) -> None: + self.closed = True + + +def test_apply_closes_owned_pull_request_after_default_branch_compliance( + tmp_path: Path, +) -> None: + checkout = tmp_path / "repository" + checkout.mkdir() + (checkout / "required.txt").write_text("yes\n") + policy = Policy( + "example", + "Example", + None, + None, + (EnsureLine(Path("required.txt"), "yes", ()),), + ) + client = CompliantPullRequestClient() + + outcome = _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + ) + + assert outcome.status == "pull-request-closed" + assert outcome.policy_pr_status == "closed" + assert outcome.pull_request_url == client.pull_request.url + assert client.verified + assert client.closed + + +def test_apply_closes_owned_pull_request_when_policy_is_not_applicable( + tmp_path: Path, +) -> None: + checkout = tmp_path / "repository" + checkout.mkdir() + policy = Policy( + "example", + "Example", + None, + BazelCondition(("missing_dependency",)), + (), + ) + client = CompliantPullRequestClient() + + outcome = _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + ) + + assert outcome.when == "no (live)" + assert outcome.status == "not-applicable" + assert outcome.policy_pr_status == "closed" + assert outcome.pull_request_url == client.pull_request.url + assert client.verified + assert client.closed + + +def test_compliant_pull_request_is_not_closed_when_branch_head_changed( + tmp_path: Path, +) -> None: + checkout = tmp_path / "repository" + checkout.mkdir() + (checkout / "required.txt").write_text("yes\n") + policy = Policy( + "example", + "Example", + None, + None, + (EnsureLine(Path("required.txt"), "yes", ()),), + ) + client = CompliantPullRequestClient( + verification_failure=RepoPolicySyncError("branch changed") + ) + + with pytest.raises(RepoPolicySyncError, match="branch changed"): + _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + ) + + assert client.verified + assert not client.closed + + +def test_recreate_rebuilds_the_existing_policy_branch(tmp_path: Path) -> None: + checkout = tmp_path / "repository" + checkout.mkdir() + (checkout / ".bazelversion").write_text("8.5.0\n") + policy = Policy( + id="example", + title="Example", + description=None, + bazel_condition=None, + ensure=(EnsureLine(Path(".bazelversion"), "8.6.0", ()),), + ) + client = RecreateClient(has_changes=True) + + outcome = _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + recreate=True, + ) + + assert outcome.status == "pull-request-recreated" + assert client.recreated_branch + assert client.force_pushed + assert client.updated + + +def test_recreate_does_not_push_without_a_diff(tmp_path: Path) -> None: + checkout = tmp_path / "repository" + checkout.mkdir() + (checkout / ".bazelversion").write_text("8.5.0\n") + policy = Policy( + "example", + "Example", + None, + None, + (EnsureLine(Path(".bazelversion"), "8.6.0", ()),), + ) + client = RecreateClient(has_changes=False) + + outcome = _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + recreate=True, + ) + + assert outcome.status == "pull-request-recreated-no-changes" + assert not client.force_pushed + assert client.updated + + +def test_existing_compliant_pull_request_updates_only_stale_body( + tmp_path: Path, +) -> None: + checkout = tmp_path / "repository" + checkout.mkdir() + (checkout / ".bazelversion").write_text("8.5.0\n") + policy = Policy( + "example", + "Example", + None, + None, + (EnsureLine(Path(".bazelversion"), "8.6.0", ()),), + ) + client = ImplicitRecreateClient(mergeable="MERGEABLE", body="stale body") + + outcome = _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + ) + + assert outcome.status == "pull-request-updated" + assert client.updated + assert not client.recreated_branch + assert not client.force_pushed + + +def test_existing_compliant_conflicted_pull_request_is_recreated( + tmp_path: Path, +) -> None: + checkout = tmp_path / "repository" + checkout.mkdir() + (checkout / ".bazelversion").write_text("8.5.0\n") + policy = Policy( + "example", + "Example", + None, + None, + (EnsureLine(Path(".bazelversion"), "8.6.0", ()),), + ) + client = ImplicitRecreateClient(mergeable="CONFLICTING", body="stale body") + + outcome = _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + ) + + assert outcome.status == "pull-request-recreated" + assert client.recreated_branch + assert client.force_pushed + assert client.updated + + +def test_existing_compliant_pull_request_is_left_alone_with_current_body( + tmp_path: Path, +) -> None: + checkout = tmp_path / "repository" + checkout.mkdir() + (checkout / ".bazelversion").write_text("8.5.0\n") + policy = Policy( + "example", + "Example", + None, + None, + (EnsureLine(Path(".bazelversion"), "8.6.0", ()),), + ) + change = runner.evaluate_policy(checkout, policy).changes + client = ImplicitRecreateClient( + mergeable="MERGEABLE", + body=_pull_request_body(policy, change, head_oid="a" * 40), + ) + + outcome = _run_repository( + client=client, + org="eclipse-score", + repository="candidate", + default_branch="main", + policy=policy, + checkout=checkout, + apply=True, + ) + + assert outcome.status == "pull-request-open" + assert not client.updated + assert not client.recreated_branch + + +class RecreateClient: + pull_request = PullRequest( + 1, "https://github.example/eclipse-score/candidate/pull/1", "a" * 40 + ) + + def __init__(self, *, has_changes: bool) -> None: + self._has_changes = has_changes + self.recreated_branch = False + self.force_pushed = False + self.updated = False + + def find_open_pull_request(self, **_: object) -> PullRequest: + return self.pull_request + + def verify_policy_branch_head(self, **_: object) -> None: + pass + + def recreate_policy_branch(self, **_: object) -> None: + self.recreated_branch = True + + def has_changes(self, **_: object) -> bool: + return self._has_changes + + def commit_and_force_push(self, **_: object) -> CommitResult: + self.force_pushed = True + return CommitResult("b" * 40) + + def update_pull_request(self, **_: object) -> None: + self.updated = True + + +class ImplicitRecreateClient: + def __init__(self, *, mergeable: str, body: str) -> None: + self.pull_request = PullRequest( + 1, + "https://github.example/eclipse-score/candidate/pull/1", + "a" * 40, + body=body, + mergeable=mergeable, + ) + self.recreated_branch = False + self.force_pushed = False + self.updated = False + + def find_open_pull_request(self, **_: object) -> PullRequest: + return self.pull_request + + def verify_policy_branch_head(self, **_: object) -> None: + pass + + def switch_to_policy_branch(self, *, checkout: Path, **_: object) -> None: + (checkout / ".bazelversion").write_text("8.6.0\n") + + def restore_synced_default_branch(self, *, checkout: Path) -> None: + (checkout / ".bazelversion").write_text("8.5.0\n") + + def recreate_policy_branch(self, **_: object) -> None: + self.recreated_branch = True + + def has_changes(self, **_: object) -> bool: + return self.recreated_branch + + def commit_and_force_push(self, **_: object) -> CommitResult: + self.force_pushed = True + return CommitResult("b" * 40) + + def update_pull_request(self, **_: object) -> None: + self.updated = True diff --git a/repo_policy_sync/tests/test_samples.py b/repo_policy_sync/tests/test_samples.py new file mode 100644 index 0000000..bcde402 --- /dev/null +++ b/repo_policy_sync/tests/test_samples.py @@ -0,0 +1,92 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import json +from pathlib import Path +from shutil import copytree + +from repo_policy_sync.models import ( + FileContainsAnyCondition, + FileContainsCondition, + Policy, + Repository, +) +from repo_policy_sync.samples import collect_samples + + +class FakeSampleClient: + def __init__(self, source: Path) -> None: + self.source = source + self.repositories = ( + Repository("docs-repo", "main"), + Repository("archived-repo", "main", archived=True), + ) + self.authenticated = False + + def ensure_authenticated(self) -> None: + self.authenticated = True + + def list_repositories(self, *, org: str) -> tuple[Repository, ...]: + return self.repositories + + def sync_default_branch( + self, *, repository: str, branch: str, destination: Path + ) -> None: + copytree(self.source, destination) + + +def test_collect_samples_uses_policy_when_and_writes_inventory(tmp_path: Path) -> None: + source = tmp_path / "source" + workflow = source / ".github/workflows/docs.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: Documentation\njobs:\n docs:\n uses: example/cicd/docs.yml@ref\n" + ) + output = tmp_path / "samples" + policy = Policy( + id="docs-policy", + title="Docs", + description=None, + bazel_condition=None, + ensure=(), + file_contains_any_condition=FileContainsAnyCondition( + ( + FileContainsCondition( + Path(".github/workflows/*.yml"), + r"uses:\s+example/cicd/docs\.yml@", + ), + ) + ), + ) + client = FakeSampleClient(source) + + report = collect_samples( + client=client, + org="example", + policies=(policy,), + repository_names=(), + checkout_cache_directory=tmp_path / "cache", + output_directory=output, + sync_workers=1, + progress=lambda _: None, + ) + + sample = output / "docs-policy/docs-repo/before/.github/workflows/docs.yml" + assert client.authenticated + assert report.cases[0].repository == "docs-repo" + assert sample.read_text() == workflow.read_text() + inventory = json.loads((output / "inventory.json").read_text()) + assert inventory["schema_version"] == 1 + assert inventory["cases"][0]["files"] == [ + {"path": ".github/workflows/docs.yml", "size": workflow.stat().st_size} + ] diff --git a/repo_policy_sync/tests/test_synchronize_workflow.py b/repo_policy_sync/tests/test_synchronize_workflow.py new file mode 100644 index 0000000..f8161f0 --- /dev/null +++ b/repo_policy_sync/tests/test_synchronize_workflow.py @@ -0,0 +1,120 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from pathlib import Path + +import pytest + +from repo_policy_sync.engine import apply_policy +from repo_policy_sync.errors import RepoPolicySyncError +from repo_policy_sync.models import Policy, SynchronizeWorkflow + + +def test_synchronize_workflow_preserves_name_and_updates_workflow_run( + tmp_path: Path, +) -> None: + workflows = tmp_path / ".github/workflows" + workflows.mkdir(parents=True) + build = workflows / "custom-docs.yml" + build.write_text( + "name: Repository Docs\n" + "on:\n" + " push:\n" + " branches: [main]\n" + "jobs:\n" + " build:\n" + " uses: example/cicd/docs.yml@v0.0.2\n" + ) + workflow_run = workflows / "docs-publish.yml" + workflow_run.write_text( + "name: Publish\n" + "on:\n" + " workflow_run:\n" + " workflows: [Documentation]\n" + "jobs:\n" + " publish:\n" + " uses: example/cicd/publish.yml@v0.0.2\n" + ) + operation = SynchronizeWorkflow( + source=Path("docs.yml"), + contents=( + "name: Documentation\n" + "permissions:\n" + " contents: read\n" + "on:\n" + " pull_request:\n" + " types: [opened, synchronize]\n" + "jobs:\n" + " docs-build:\n" + " uses: example/cicd/docs.yml@source\n" + " permissions:\n" + " contents: read\n" + " actions: write\n" + ), + reusable_workflow="example/cicd/docs.yml", + minimum_version=(0, 0, 3), + required_triggers=("pull_request",), + workflow_run_path=Path(".github/workflows/docs-publish.yml"), + workflow_run_contents=( + "name: Publish Documentation\n" + "on:\n" + " workflow_run:\n" + " workflows: [Documentation]\n" + "jobs:\n" + " docs-publish:\n" + " uses: example/cicd/publish.yml@source\n" + ), + ) + + apply_policy(tmp_path, Policy("example", "Example", None, None, (operation,))) + + build_result = build.read_text() + workflow_run_result = workflow_run.read_text() + assert "name: Repository Docs\n" in build_result + assert " pull_request:\n" in build_result + assert "example/cicd/docs.yml@source" in build_result + assert ( + " permissions:\n contents: read\n actions: write\n" in build_result + ) + assert 'workflows: ["Repository Docs"]' in workflow_run_result + assert "example/cicd/publish.yml@source" in workflow_run_result + + +def test_synchronize_workflow_rejects_ambiguous_selection(tmp_path: Path) -> None: + workflows = tmp_path / ".github/workflows" + workflows.mkdir(parents=True) + for name in ("first.yml", "second.yml"): + (workflows / name).write_text( + "name: Docs\n" + "on: [push]\n" + "jobs:\n" + " docs:\n" + " uses: example/cicd/docs.yml@v0.0.3\n" + ) + operation = SynchronizeWorkflow( + source=Path("docs.yml"), + contents=( + "name: Docs\n" + "on:\n" + " push:\n" + "jobs:\n" + " docs:\n" + " uses: example/cicd/docs.yml@source\n" + ), + reusable_workflow="example/cicd/docs.yml", + minimum_version=(0, 0, 3), + required_triggers=("push",), + ) + + with pytest.raises(RepoPolicySyncError, match="only one workflow"): + apply_policy(tmp_path, Policy("example", "Example", None, None, (operation,))) diff --git a/uv.lock b/uv.lock index bd25d70..f972bf3 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "bazel-runfiles" version = "1.3.0" @@ -10,6 +19,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/5e/c178358cd38c8431db47866698f4eb65c33b4b6a3cbee05d4de8f7a51788/bazel_runfiles-1.3.0-py3-none-any.whl", hash = "sha256:3978fa1c8225686d39aa0d9523860e3e1e6d34297066f7c97ea2eeafc776b097", size = 7582, upload-time = "2025-03-27T18:32:37.168Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -19,6 +37,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/30/03b03951873a1a0ffc7e8ca0e10c15597b59e8d0e39260704cd2ea087bc4/filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30", size = 222126, upload-time = "2026-08-23T17:37:55.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -28,6 +73,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -37,6 +91,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "platformdirs" +version = "4.11.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/bb/ebc6636e1ae41314f796ebb7215fd28febb45f9aac72f2b04cb74b5071dc/platformdirs-4.11.4.tar.gz", hash = "sha256:f3373be828247211d0febabea97e238c3dfde8a60b3c90c32756fb52cb21556d", size = 34079, upload-time = "2026-08-24T14:53:49.676Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/be/0ff05fcd2938fb58ad9219bd54135968342d214737e012d62d43f06a2dd6/platformdirs-4.11.4-py3-none-any.whl", hash = "sha256:e34ff91a24bcddc6d939b878bdf3f5c437c9c46fe9e212b1bf455fdf1ee57586", size = 23741, upload-time = "2026-08-24T14:53:48.406Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -46,6 +109,112 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -71,17 +240,152 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-discovery" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/8f/3c92c45737f654f2488ab3662b7604a55d3d35146d37c9ce80f5c95b95a6/python_discovery-1.5.3.tar.gz", hash = "sha256:e500eb24025fb7c4876c1fdcfbafd9028a10c71b661aee38cb6fb0de594518c1", size = 82477, upload-time = "2026-08-24T14:48:46.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/12/823d9a321904ccfd2969a24b84fdfd1e6614c707ec569c62879bf1dbc6c5/python_discovery-1.5.3-py3-none-any.whl", hash = "sha256:8305296358f1aa2ed302a25b84be7df84fef8ca47c7dce2da63cb7325333044e", size = 38290, upload-time = "2026-08-24T14:48:45.305Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, + { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, + { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, + { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, + { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, + { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, + { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +] + [[package]] name = "tools" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "bazel-runfiles" }, + { name = "pre-commit" }, + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "bazel-runfiles", specifier = "==1.3.0" }, + { name = "pre-commit", specifier = ">=4.0.0" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[package.metadata.requires-dev] +dev = [ { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = "==0.15.10" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.7.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/60/fc54e876e34f94dd0cf0185aaecfd4bfa906653f003d9b2fb21428642fca/virtualenv-21.7.5.tar.gz", hash = "sha256:a73c4246fba3c8901ff9717399f466e00eeca5a3834981f1a6ebb4f1e94de2f8", size = 5346743, upload-time = "2026-08-25T05:39:16.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/d8/401141bf45637be916c86d325bd821c5838c7eff83294b934cd94e774e4f/virtualenv-21.7.5-py3-none-any.whl", hash = "sha256:e36ca889510ab6cb0b1dca93c59e5431dd4422a3c88f487358d470c90af8c07a", size = 5324697, upload-time = "2026-08-25T05:39:14.229Z" }, ]