From 2b8dee8c45b48780f628572eb766aae58bff8142 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 00:37:46 +0200 Subject: [PATCH 01/12] Add repository policy sync v0 --- .github/workflows/repo-policy-sync.yml | 47 + .github/workflows/tests.yml | 11 +- .pre-commit-config.yaml | 2 +- README.md | 7 + cr_checker/tests/test_cr_checker.py | 9 +- pyproject.toml | 23 +- repo_policy_sync/README.md | 161 +++ repo_policy_sync/__init__.py | 14 + repo_policy_sync/bazel.py | 66 + repo_policy_sync/cache.py | 27 + repo_policy_sync/cli.py | 332 +++++ repo_policy_sync/config.py | 210 ++++ repo_policy_sync/docs/README.md | 48 + .../docs/explanation/alternatives.md | 86 ++ .../docs/explanation/architecture.md | 82 ++ .../docs/explanation/execution-model.md | 80 ++ .../docs/explanation/pull-request-safety.md | 57 + repo_policy_sync/docs/how-to/run-a-policy.md | 158 +++ repo_policy_sync/docs/reference/cli.md | 79 ++ .../docs/reference/configuration.md | 68 ++ .../docs/reference/policy-format.md | 311 +++++ .../docs/tutorials/first-policy.md | 73 ++ repo_policy_sync/eclipse-score.toml | 2 + repo_policy_sync/engine.py | 214 ++++ repo_policy_sync/errors.py | 64 + repo_policy_sync/github.py | 869 +++++++++++++ repo_policy_sync/models.py | 227 ++++ repo_policy_sync/operations/README.md | 63 + repo_policy_sync/operations/__init__.py | 131 ++ repo_policy_sync/operations/_validation.py | 62 + .../operations/ensure_bazel_dependency.py | 166 +++ repo_policy_sync/operations/ensure_line.py | 120 ++ .../operations/ensure_minimum_version.py | 88 ++ .../operations/ensure_no_such_file.py | 52 + .../operations/migrate_devcontainer_json.py | 320 +++++ repo_policy_sync/operations/replace_regex.py | 78 ++ .../synchronize_bazel_dependencies.py | 479 ++++++++ .../synchronize_devcontainer_version.py | 191 +++ .../operations/synchronize_file.py | 324 +++++ repo_policy_sync/policies/README.md | 65 + .../legacy-files/after/.gitignore | 3 + .../legacy-files/after/MODULE.bazel | 15 + .../legacy-files/before/.gitignore | 4 + .../legacy-files/before/MODULE.bazel | 15 + .../legacy-files/before/ubproject.toml | 1 + .../not-a-docs-repository/after/.gitignore | 1 + .../not-a-docs-repository/after/MODULE.bazel | 15 + .../after/ubproject.toml | 1 + .../not-a-docs-repository/before/.gitignore | 1 + .../not-a-docs-repository/before/MODULE.bazel | 15 + .../before/ubproject.toml | 1 + .../policy.yml | 35 + .../after/MODULE.bazel | 14 + .../before/MODULE.bazel | 17 + .../legacy-module/after/MODULE.bazel | 16 + .../legacy-module/before/MODULE.bazel | 20 + .../policy.yml | 20 + .../version-only/after/MODULE.bazel | 14 + .../version-only/before/MODULE.bazel | 17 + .../minimum/after/.bazelversion | 1 + .../minimum/before/.bazelversion | 1 + .../newer-minor/after/.bazelversion | 1 + .../newer-minor/before/.bazelversion | 1 + .../newer-patch/after/.bazelversion | 1 + .../newer-patch/before/.bazelversion | 1 + .../older-major/after/.bazelversion | 1 + .../older-major/before/.bazelversion | 1 + .../older-minor/after/.bazelversion | 1 + .../older-minor/before/.bazelversion | 1 + .../policies/minimum-bazel-version/policy.yml | 24 + .../already-matching/after/BUILD | 14 + .../already-matching/after/MODULE.bazel | 17 + .../already-matching/before/BUILD | 14 + .../already-matching/before/MODULE.bazel | 17 + .../baselibs-only/after/MODULE.bazel | 21 + .../baselibs-only/before/MODULE.bazel | 15 + .../build-reference-only/after/MODULE.bazel | 17 + .../after/subproject/BUILD | 14 + .../build-reference-only/before/MODULE.bazel | 17 + .../before/subproject/BUILD | 14 + .../description-old/after/BUILD | 14 + .../description-old/after/MODULE.bazel | 17 + .../description-old/before/BUILD | 14 + .../description-old/before/MODULE.bazel | 17 + .../legacy-and-old/after/BUILD | 14 + .../legacy-and-old/after/MODULE.bazel | 17 + .../after/components/BUILD.bazel | 14 + .../legacy-and-old/before/BUILD | 14 + .../legacy-and-old/before/MODULE.bazel | 17 + .../before/components/BUILD.bazel | 14 + .../policy.yml | 52 + .../after/.devcontainer/Dockerfile | 15 + .../after/.devcontainer/devcontainer.json | 7 + .../image-based/before/.devcontainer.json | 5 + .../after/.devcontainer/Dockerfile | 15 + .../after/.devcontainer/devcontainer.json | 7 + .../before/.devcontainer/devcontainer.json | 5 + .../policy.yml | 32 + .../after/.devcontainer/Dockerfile | 1 + .../after/.devcontainer/run-tool | 41 + .../after/.pre-commit-config.yaml | 17 + .../already-matching/after/MODULE.bazel | 19 + .../before/.devcontainer/Dockerfile | 1 + .../before/.devcontainer/run_tool.sh | 15 + .../before/.pre-commit-config.yaml | 17 + .../already-matching/before/MODULE.bazel | 14 + .../after/.devcontainer/Dockerfile | 1 + .../after/.devcontainer/run-tool | 41 + .../after/.pre-commit-config.yaml | 19 + .../after/MODULE.bazel | 15 + .../before/.devcontainer/Dockerfile | 1 + .../before/.pre-commit-config.yaml | 19 + .../before/MODULE.bazel | 15 + .../before/tools/run_tool.sh | 15 + .../policy.yml | 49 + .../run-tool | 41 + .../after/.devcontainer/Dockerfile | 1 + .../already-matching/after/MODULE.bazel | 15 + .../before/.devcontainer/Dockerfile | 1 + .../already-matching/before/MODULE.bazel | 15 + .../after/.devcontainer/Dockerfile | 3 + .../dockerfile-higher/after/MODULE.bazel | 19 + .../before/.devcontainer/Dockerfile | 3 + .../dockerfile-higher/before/MODULE.bazel | 19 + .../after/.devcontainer/Dockerfile | 3 + .../module-higher/after/MODULE.bazel | 19 + .../before/.devcontainer/Dockerfile | 3 + .../module-higher/before/MODULE.bazel | 19 + .../policy.yml | 34 + .../after/.github/workflows/docs-publish.yml | 29 + .../after/.github/workflows/docs.yml | 29 + .../before/.github/workflows/docs-publish.yml | 29 + .../before/.github/workflows/docs.yml | 29 + .../docs-publish.yml | 29 + .../score-docs-workflow-alignment/docs.yml | 27 + .../after/.github/workflows/docs-publish.yml | 29 + .../after/.github/workflows/docs.yml | 34 + .../before/.github/workflows/docs.yml | 33 + .../after/.github/workflows/docs-publish.yml | 29 + .../after/.github/workflows/docs.yml | 27 + .../before/.github/workflows/docs.yml | 18 + .../score-docs-workflow-alignment/policy.yml | 38 + repo_policy_sync/policy.py | 389 ++++++ repo_policy_sync/reporting.py | 578 +++++++++ repo_policy_sync/runner.py | 978 +++++++++++++++ repo_policy_sync/templates/pull_request.md | 40 + repo_policy_sync/tests/test_cache.py | 25 + repo_policy_sync/tests/test_cli.py | 436 +++++++ repo_policy_sync/tests/test_config.py | 97 ++ repo_policy_sync/tests/test_engine.py | 837 +++++++++++++ repo_policy_sync/tests/test_github.py | 1084 +++++++++++++++++ .../tests/test_policy_and_metrics.py | 561 +++++++++ .../tests/test_policy_fixtures.py | 50 + repo_policy_sync/tests/test_reporting.py | 287 +++++ repo_policy_sync/tests/test_runner.py | 1057 ++++++++++++++++ uv.lock | 209 +++- 156 files changed, 13667 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/repo-policy-sync.yml create mode 100644 repo_policy_sync/README.md create mode 100644 repo_policy_sync/__init__.py create mode 100644 repo_policy_sync/bazel.py create mode 100644 repo_policy_sync/cache.py create mode 100644 repo_policy_sync/cli.py create mode 100644 repo_policy_sync/config.py create mode 100644 repo_policy_sync/docs/README.md create mode 100644 repo_policy_sync/docs/explanation/alternatives.md create mode 100644 repo_policy_sync/docs/explanation/architecture.md create mode 100644 repo_policy_sync/docs/explanation/execution-model.md create mode 100644 repo_policy_sync/docs/explanation/pull-request-safety.md create mode 100644 repo_policy_sync/docs/how-to/run-a-policy.md create mode 100644 repo_policy_sync/docs/reference/cli.md create mode 100644 repo_policy_sync/docs/reference/configuration.md create mode 100644 repo_policy_sync/docs/reference/policy-format.md create mode 100644 repo_policy_sync/docs/tutorials/first-policy.md create mode 100644 repo_policy_sync/eclipse-score.toml create mode 100644 repo_policy_sync/engine.py create mode 100644 repo_policy_sync/errors.py create mode 100644 repo_policy_sync/github.py create mode 100644 repo_policy_sync/models.py create mode 100644 repo_policy_sync/operations/README.md create mode 100644 repo_policy_sync/operations/__init__.py create mode 100644 repo_policy_sync/operations/_validation.py create mode 100644 repo_policy_sync/operations/ensure_bazel_dependency.py create mode 100644 repo_policy_sync/operations/ensure_line.py create mode 100644 repo_policy_sync/operations/ensure_minimum_version.py create mode 100644 repo_policy_sync/operations/ensure_no_such_file.py create mode 100644 repo_policy_sync/operations/migrate_devcontainer_json.py create mode 100644 repo_policy_sync/operations/replace_regex.py create mode 100644 repo_policy_sync/operations/synchronize_bazel_dependencies.py create mode 100644 repo_policy_sync/operations/synchronize_devcontainer_version.py create mode 100644 repo_policy_sync/operations/synchronize_file.py create mode 100644 repo_policy_sync/policies/README.md create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/after/.gitignore create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/.gitignore create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/legacy-files/before/ubproject.toml create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/.gitignore create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/after/ubproject.toml create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/.gitignore create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/not-a-docs-repository/before/ubproject.toml create mode 100644 repo_policy_sync/policies/docs-as-code-legacy-configuration-removal/policy.yml create mode 100644 repo_policy_sync/policies/minimal-bazel-module-declaration/compatibility-level-only/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/minimal-bazel-module-declaration/compatibility-level-only/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/minimal-bazel-module-declaration/legacy-module/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/minimal-bazel-module-declaration/legacy-module/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/minimal-bazel-module-declaration/policy.yml create mode 100644 repo_policy_sync/policies/minimal-bazel-module-declaration/version-only/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/minimal-bazel-module-declaration/version-only/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/minimum-bazel-version/minimum/after/.bazelversion create mode 100644 repo_policy_sync/policies/minimum-bazel-version/minimum/before/.bazelversion create mode 100644 repo_policy_sync/policies/minimum-bazel-version/newer-minor/after/.bazelversion create mode 100644 repo_policy_sync/policies/minimum-bazel-version/newer-minor/before/.bazelversion create mode 100644 repo_policy_sync/policies/minimum-bazel-version/newer-patch/after/.bazelversion create mode 100644 repo_policy_sync/policies/minimum-bazel-version/newer-patch/before/.bazelversion create mode 100644 repo_policy_sync/policies/minimum-bazel-version/older-major/after/.bazelversion create mode 100644 repo_policy_sync/policies/minimum-bazel-version/older-major/before/.bazelversion create mode 100644 repo_policy_sync/policies/minimum-bazel-version/older-minor/after/.bazelversion create mode 100644 repo_policy_sync/policies/minimum-bazel-version/older-minor/before/.bazelversion create mode 100644 repo_policy_sync/policies/minimum-bazel-version/policy.yml create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/after/BUILD create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/before/BUILD create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/already-matching/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/baselibs-only/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/baselibs-only/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/after/subproject/BUILD create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/build-reference-only/before/subproject/BUILD create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/after/BUILD create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/before/BUILD create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/description-old/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/BUILD create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/after/components/BUILD.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/BUILD create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/legacy-and-old/before/components/BUILD.bazel create mode 100644 repo_policy_sync/policies/score-bazel-dependency-alignment/policy.yml create mode 100644 repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/after/.devcontainer/Dockerfile create mode 100644 repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/after/.devcontainer/devcontainer.json create mode 100644 repo_policy_sync/policies/score-devcontainer-dockerfile-migration/image-based/before/.devcontainer.json create mode 100644 repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/after/.devcontainer/Dockerfile create mode 100644 repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/after/.devcontainer/devcontainer.json create mode 100644 repo_policy_sync/policies/score-devcontainer-dockerfile-migration/nested-image-based/before/.devcontainer/devcontainer.json create mode 100644 repo_policy_sync/policies/score-devcontainer-dockerfile-migration/policy.yml create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.devcontainer/Dockerfile create mode 100755 repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.devcontainer/run-tool create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/.pre-commit-config.yaml create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/already-matching/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.devcontainer/Dockerfile create mode 100755 repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.devcontainer/run_tool.sh create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/.pre-commit-config.yaml create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/already-matching/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.devcontainer/Dockerfile create mode 100755 repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.devcontainer/run-tool create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/.pre-commit-config.yaml create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/.devcontainer/Dockerfile create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/.pre-commit-config.yaml create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/MODULE.bazel create mode 100755 repo_policy_sync/policies/score-devcontainer-standardization/legacy-run-tool-migration/before/tools/run_tool.sh create mode 100644 repo_policy_sync/policies/score-devcontainer-standardization/policy.yml create mode 100755 repo_policy_sync/policies/score-devcontainer-standardization/run-tool create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/after/.devcontainer/Dockerfile create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/before/.devcontainer/Dockerfile create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/already-matching/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/after/.devcontainer/Dockerfile create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/before/.devcontainer/Dockerfile create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/dockerfile-higher/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/after/.devcontainer/Dockerfile create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/before/.devcontainer/Dockerfile create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/module-higher/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/score-devcontainer-version-alignment/policy.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/after/.github/workflows/docs-publish.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/after/.github/workflows/docs.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/before/.github/workflows/docs-publish.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/before/.github/workflows/docs.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/docs-publish.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/after/.github/workflows/docs-publish.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/after/.github/workflows/docs.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/before/.github/workflows/docs.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/after/.github/workflows/docs-publish.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/after/.github/workflows/docs.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/before/.github/workflows/docs.yml create mode 100644 repo_policy_sync/policies/score-docs-workflow-alignment/policy.yml create mode 100644 repo_policy_sync/policy.py create mode 100644 repo_policy_sync/reporting.py create mode 100644 repo_policy_sync/runner.py create mode 100644 repo_policy_sync/templates/pull_request.md create mode 100644 repo_policy_sync/tests/test_cache.py create mode 100644 repo_policy_sync/tests/test_cli.py create mode 100644 repo_policy_sync/tests/test_config.py create mode 100644 repo_policy_sync/tests/test_engine.py create mode 100644 repo_policy_sync/tests/test_github.py create mode 100644 repo_policy_sync/tests/test_policy_and_metrics.py create mode 100644 repo_policy_sync/tests/test_policy_fixtures.py create mode 100644 repo_policy_sync/tests/test_reporting.py create mode 100644 repo_policy_sync/tests/test_runner.py 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..f7746b8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,6 +36,13 @@ 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: uv run --with dist/*.whl 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 1d5fad9..1755987 100644 --- a/cr_checker/tests/test_cr_checker.py +++ b/cr_checker/tests/test_cr_checker.py @@ -40,7 +40,6 @@ def load_template(extension: str) -> str: return templates[extension] - # test that offset matches the length of the shebang line including trailing newlines def test_detect_shebang_offset_counts_trailing_newlines(tmp_path): cr_checker = load_cr_checker_module() @@ -73,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) @@ -104,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", @@ -291,7 +289,8 @@ def test_process_files_accepts_flexible_border(tmp_path): " * terms of the Apache License Version 2.0 which is available at\n" " * https://www.apache.org/licenses/LICENSE-2.0\n" " *\n" - " * SPDX-" "License-Identifier: Apache-2.0\n" + " * SPDX-" + "License-Identifier: Apache-2.0\n" " /////////////////////////////////////////////////////////////////////////////////////\n" ) test_file.write_text(header + "int main() {}\n", encoding="utf-8") diff --git a/pyproject.toml b/pyproject.toml index ce807fb..cf3ed59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,31 @@ [project] name = "tools" -version = "0.1.0" +version = "0.0.0" description = "Gathering of different small tools used throughout S-CORE" readme = "README.md" requires-python = ">=3.12" dependencies = [ "bazel-runfiles==1.3.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..0374874 --- /dev/null +++ b/repo_policy_sync/README.md @@ -0,0 +1,161 @@ + + +# 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 --org eclipse-score + +# Add another local policy directory when needed. +uv run score-repo-policy-sync --org eclipse-score \ + --policy-dir shared-policies + +# Apply: create or update policy-owned pull requests. +uv run score-repo-policy-sync --org eclipse-score --apply +``` + +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 --org eclipse-score --apply --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 \ + --org eclipse-score \ + --repo reference_integration \ + --policy-dir repo_policy_sync/policies \ + --policy minimum-bazel-version + +uv run score-repo-policy-sync \ + --org etas \ + --repo reference_integration +``` + +To exclude a bundled policy for a repository or rollout: + +```bash +uv run score-repo-policy-sync \ + --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 --org eclipse-score --repo reference_integration \ + --policy minimum-bazel-version --apply --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..2072441 --- /dev/null +++ b/repo_policy_sync/bazel.py @@ -0,0 +1,66 @@ +# ******************************************************************************* +# 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 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..840aa99 --- /dev/null +++ b/repo_policy_sync/cli.py @@ -0,0 +1,332 @@ +# ******************************************************************************* +# 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 + + +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 --config, --json-output, and --markdown-output " + "may also be set in the TOML configuration. " + "Explicit command-line values override configuration values." + ), + ) + 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 local policy by name. Repeat to select policies; defaults to all local policies.", + ) + typical.add_argument( + "--repo", + action="append", + default=None, + help="Exact repository name to include. Repeat to include more repositories.", + ) + typical.add_argument( + "--apply", + action=argparse.BooleanOptionalAction, + default=None, + help="Commit, push, and create or update pull requests for required changes.", + ) + rare.add_argument( + "--config", + type=Path, + help="TOML configuration file (default: score-repo-policy-sync.toml if present).", + ) + 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( + "--recreate", + action=argparse.BooleanOptionalAction, + default=None, + help="Recreate one existing policy-owned pull request from the current default branch (requires --apply).", + ) + rare.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." + ), + ) + 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)." + ), + ) + return parser + + +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 ()) + ) + apply = args.apply if args.apply is not None else (config.apply or False) + 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 not apply: + parser.error("--recreate requires --apply") + 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) + report = run_policies( + client=GitHubCli(), + org=org, + policies=policies, + repository_names=repository_names, + checkout_cache_directory=cache_directory, + apply=apply, + 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 apply 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 ()) + ) + if configured_directories: + try: + return resolve_policy_names(policy_names, configured_directories) + except PolicyError as exc: + if not str(exc).startswith("unknown policy name(s):"): + raise + return resolve_policy_names(policy_names, BUNDLED_POLICY_DIRECTORY) + + +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..dc862b0 --- /dev/null +++ b/repo_policy_sync/config.py @@ -0,0 +1,210 @@ +# ******************************************************************************* +# 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 + apply: bool | 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", + "apply", + "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) + apply = _optional_bool(section, "apply", 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, + apply=apply, + 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..0d5738a --- /dev/null +++ b/repo_policy_sync/docs/explanation/execution-model.md @@ -0,0 +1,80 @@ + + +# 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 `pre-commit run --all-files` + 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..e36d122 --- /dev/null +++ b/repo_policy_sync/docs/how-to/run-a-policy.md @@ -0,0 +1,158 @@ + + +# 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 --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 \ + --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 \ + --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. + +Limit a rollout to selected policy and repository names with repeatable +`--policy` and `--repo` options: + +```bash +uv run score-repo-policy-sync \ + --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 \ + --org eclipse-score \ + --repo reference_integration \ + --policy minimum-bazel-version \ + --apply +``` + +## 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 not pass `--apply`. + +Apply mode runs the target repository's `pre-commit run --all-files` command +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 \ + --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..176881d --- /dev/null +++ b/repo_policy_sync/docs/reference/cli.md @@ -0,0 +1,79 @@ + + +# CLI reference + +```text +score-repo-policy-sync [--org ORGANIZATION] [OPTIONS] +``` + +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. + +## 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. | +| `--apply`, `--no-apply` | Commit, push, and create or update policy-owned pull requests. Without this flag, the command only plans changes. | +| *(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`, `--no-recreate` | Rebuild one existing policy-owned pull request from its repository's current default branch. Requires `--apply`, 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. | + +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. + +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..cc5b64b --- /dev/null +++ b/repo_policy_sync/docs/reference/configuration.md @@ -0,0 +1,68 @@ + + +# 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"] +apply = false + +# 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` | +| `apply` | `--apply` / `--no-apply` | +| `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..488256f --- /dev/null +++ b/repo_policy_sync/docs/reference/policy-format.md @@ -0,0 +1,311 @@ + + +# 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_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 top-level `permissions` block is removed so the +shared documentation build remains unprivileged; jobs needing 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 + copyright_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_organization` is +set, the standard Eclipse Foundation copyright header is added only when the +policy is applied to that organization. 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..b296578 --- /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 \ + --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..de39270 --- /dev/null +++ b/repo_policy_sync/engine.py @@ -0,0 +1,214 @@ +# ******************************************************************************* +# 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 re +import shlex +import subprocess +from pathlib import Path + +from .bazel import matches_bazel_dependency_condition, parse_bazel_version +from .errors import CommandError, redact_sensitive_text +from .models import Change, Evaluation, Policy, SynchronizeBazelDependencies +from .operations import apply as apply_operation +from .operations import describe_changes + +_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) +_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") +_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*[\"']([^\"']+)[\"']") + + +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 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: + try: + subprocess.run( + command, + cwd=root, + check=True, + capture_output=True, + text=True, + ) + 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: + return (root / command.when_file_exists).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" + 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 call in _BAZEL_DEP_CALL.finditer(text): + name_match = _NAME_ARGUMENT.search(call.group(1)) + if name_match is None: + continue + version_match = _VERSION_ARGUMENT.search(call.group(1)) + dependencies[name_match.group(1)] = ( + parse_bazel_version(version_match.group(1)) if version_match else None + ) + # 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 + return condition is None or (root / condition.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 "*?["): + return (root / path,) if (root / path).is_file() else () + # Glob conditions are used for files such as BUILD files at any directory depth. + return tuple( + candidate + for candidate in sorted(root.glob(str(path))) + # Git metadata is not part of the repository content being evaluated. + if candidate.is_file() and ".git" not in candidate.relative_to(root).parts + ) 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..776359b --- /dev/null +++ b/repo_policy_sync/github.py @@ -0,0 +1,869 @@ +# ******************************************************************************* +# 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: + 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) -> 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 + 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", + } + ) + self._run( + ["pre-commit", "run", "--all-files"], + 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.""" + + try: + ran = self.run_pre_commit(checkout=checkout) + except CommandError: + self._run(["git", "-C", str(checkout), "add", "-A", "--", *paths]) + try: + ran = self.run_pre_commit(checkout=checkout) + 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.""" + + self._run(["gh", "pr", "close", pull_request.url, "--repo", repository]) + + 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..877780c --- /dev/null +++ b/repo_policy_sync/models.py @@ -0,0 +1,227 @@ +# ******************************************************************************* +# 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 MigrateDevcontainerJson: + """Replace a SCORE image-based devcontainer config with a Dockerfile.""" + + sources: tuple[Path, ...] + destination: Path + dockerfile: Path + image: str + rationale: str | None = None + copyright_organization: str | None = None + + +EnsureOperation = ( + EnsureLine + | EnsureNoSuchFile + | ReplaceRegex + | EnsureMinimumVersion + | EnsureBazelDependency + | SynchronizeDevcontainerVersion + | SynchronizeBazelDependencies + | SynchronizeFile + | 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..08508a3 --- /dev/null +++ b/repo_policy_sync/operations/README.md @@ -0,0 +1,63 @@ + + +# 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. | + +All operations accept an optional `rationale`. When a change is needed, the +rationale is included with the generated change description. + +## 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..67f06e8 --- /dev/null +++ b/repo_policy_sync/operations/__init__.py @@ -0,0 +1,131 @@ +# ******************************************************************************* +# 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, + EnsureBazelDependency, + EnsureOperation, + MigrateDevcontainerJson, + SynchronizeBazelDependencies, + SynchronizeDevcontainerVersion, +) +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 + + +class OperationHandler(Protocol): + operation_type: str + + def parse(self, raw: dict[str, Any], source: Path) -> EnsureOperation: ... + + def describe_change(self, path: Path, operation: EnsureOperation) -> str | None: ... + + def apply(self, path: Path, operation: EnsureOperation) -> None: ... + + +_HANDLERS: tuple[OperationHandler, ...] = ( + EnsureLineOperation(), + EnsureMinimumVersionOperation(), + EnsureNoSuchFileOperation(), + EnsureBazelDependencyOperation(), + MigrateDevcontainerJsonOperation(), + ReplaceRegexOperation(), + SynchronizeDevcontainerVersionOperation(), + SynchronizeBazelDependenciesOperation(), + SynchronizeFileOperation(), +) +# 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.""" + + handler = _handler_for(operation) + if isinstance(operation, MigrateDevcontainerJson): + return handler.describe_changes(root, operation, organization=organization) # type: ignore[attr-defined] + if isinstance(operation, EnsureBazelDependency): + return handler.describe_changes(root, operation) # type: ignore[attr-defined] + if isinstance(operation, SynchronizeDevcontainerVersion): + return handler.describe_changes(root, operation) # type: ignore[attr-defined] + if isinstance(operation, SynchronizeBazelDependencies): + return handler.describe_changes(root, operation) # type: ignore[attr-defined] + path = root / operation.path + description = handler.describe_change(path, operation) + return ( + (Change(operation.path, description, operation.rationale),) + if description + else () + ) + + +def apply( + root: Path, operation: EnsureOperation, *, organization: str | None = None +) -> None: + """Apply one operation from a repository root.""" + + handler = _handler_for(operation) + if isinstance(operation, MigrateDevcontainerJson): + handler.apply(root, operation, organization=organization) # type: ignore[attr-defined] + return + if isinstance(operation, EnsureBazelDependency): + handler.apply(root, operation) # type: ignore[attr-defined] + return + if isinstance(operation, SynchronizeDevcontainerVersion): + handler.apply(root, operation) + return + if isinstance(operation, SynchronizeBazelDependencies): + handler.apply(root, operation) # type: ignore[attr-defined] + return + handler.apply(root / operation.path, operation) + + +def _handler_for(operation: EnsureOperation) -> OperationHandler: + for handler in _HANDLERS: + if handler.operation_class is type(operation): # type: ignore[attr-defined] + 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..574d3e5 --- /dev/null +++ b/repo_policy_sync/operations/_validation.py @@ -0,0 +1,62 @@ +# ******************************************************************************* +# 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 + + +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 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..0463315 --- /dev/null +++ b/repo_policy_sync/operations/ensure_bazel_dependency.py @@ -0,0 +1,166 @@ +# ******************************************************************************* +# 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 ..errors import RepoPolicySyncError +from ..models import Change, EnsureBazelDependency, EnsureOperation +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, +) + +_NUMERIC_VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") +_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) +_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 + ) -> 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) -> 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 + 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 + if not path.is_file(): + raise RepoPolicySyncError(f"{operation.module_file} must exist") + text = path.read_text(encoding="utf-8") + calls = [] + for match in _BAZEL_DEP_CALL.finditer(text): + name_matches = list(_NAME_ARGUMENT.finditer(match.group(1))) + 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(match) + 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(calls[0].group(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..0171c23 --- /dev/null +++ b/repo_policy_sync/operations/ensure_line.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 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 EnsureLine, EnsureOperation +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + string_list, +) + + +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_change(self, path: Path, operation: EnsureOperation) -> str | None: + assert isinstance(operation, EnsureLine) + _validate_target(path, operation) + lines = _read_lines(path) + normalized = _normalized_lines(lines, operation) + if normalized == lines: + return None + 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: + return f"replace {', '.join(repr(line) for line in dict.fromkeys(obsolete))} with {operation.line!r}" + if desired_count == 0: + return f"add {operation.line!r}" + if desired_count > 1: + return f"remove duplicate {operation.line!r} entries" + return f"normalize {operation.line!r}" + + def apply(self, path: Path, operation: EnsureOperation) -> None: + assert isinstance(operation, EnsureLine) + _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..69895be --- /dev/null +++ b/repo_policy_sync/operations/ensure_minimum_version.py @@ -0,0 +1,88 @@ +# ******************************************************************************* +# 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 EnsureMinimumVersion, EnsureOperation +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_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_change(self, path: Path, operation: EnsureOperation) -> str | None: + assert isinstance(operation, EnsureMinimumVersion) + current_version = _read_version(path, operation) + if current_version is None or current_version >= _required_version(operation): + return None + return f"upgrade from {path.read_text(encoding='utf-8').strip()!r} to {operation.minimum_version!r}" + + def apply(self, path: Path, operation: EnsureOperation) -> None: + assert isinstance(operation, EnsureMinimumVersion) + 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..9c9803c --- /dev/null +++ b/repo_policy_sync/operations/ensure_no_such_file.py @@ -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 +# ******************************************************************************* + +"""The ensure_no_such_file operation.""" + +from pathlib import Path +from typing import Any + +from ..errors import RepoPolicySyncError +from ..models import EnsureNoSuchFile, EnsureOperation +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_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_change(self, path: Path, operation: EnsureOperation) -> str | None: + if path.is_dir(): + assert isinstance(operation, EnsureNoSuchFile) + raise RepoPolicySyncError(f"refusing to remove directory {operation.path}") + return "remove file" if path.exists() else None + + def apply(self, path: Path, operation: EnsureOperation) -> None: + if not path.exists(): + return + if path.is_dir(): + assert isinstance(operation, EnsureNoSuchFile) + 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..082ac81 --- /dev/null +++ b/repo_policy_sync/operations/migrate_devcontainer_json.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 +# ******************************************************************************* + +"""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 RepoPolicySyncError +from ..models import Change, EnsureOperation, MigrateDevcontainerJson +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + string_list, +) + +_VERSION = re.compile(r"v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z") +_DOCKERFILE_COMMENT = ( + "# Use Dockerfile to get dependabot version bumps after new image is released" +) +_ECLIPSE_COPYRIGHT = """# ******************************************************************************* +# 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 +# ******************************************************************************* +""" +_IMAGE_PROPERTY = re.compile( + r'(?m)^(?P[ \t]*)"image"\s*:\s*"(?P[^"]+)"' + r"(?P,?)(?P[ \t]*(?://[^\r\n]*)?(?:\r?\n|$))" +) + + +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", + "rationale", + "copyright_organization", + }, + 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), + rationale=optional_string(raw, "rationale", source), + copyright_organization=optional_string( + raw, "copyright_organization", source + ), + ) + + 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 + migration = _migration_contents(source, operation, organization) + if migration is None: + return () + dockerfile_contents, destination_contents = migration + _validate_target(dockerfile, operation) + destination = root / operation.destination + _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 = _find_source(root, operation) + if source is None: + return + dockerfile = root / operation.dockerfile + migration = _migration_contents(source, operation, organization) + if migration is None: + return + dockerfile_contents, destination_contents = migration + _validate_target(dockerfile, operation) + destination = root / operation.destination + _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 = tuple( + (path, root / path) for path in operation.sources if (root / path).exists() + ) + 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, 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") + 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')}{match.group('tail')}" + ) + destination_contents = text[: match.start()] + replacement + text[match.end() :] + copyright_header = ( + _ECLIPSE_COPYRIGHT if operation.copyright_organization == organization else "" + ) + prefix = f"{copyright_header}\n" if copyright_header else "" + dockerfile_contents = ( + f"{prefix}{_DOCKERFILE_COMMENT}\nFROM {operation.image}:{tag}\n" + ) + return dockerfile_contents, destination_contents + + +def _strip_jsonc(text: str) -> str: + """Remove JSONC comments and trailing commas while preserving strings.""" + + result: list[str] = [] + in_string = False + escaped = False + in_line_comment = False + in_block_comment = False + index = 0 + while index < len(text): + character = text[index] + following = text[index + 1] if index + 1 < len(text) else "" + if in_line_comment: + if character in "\r\n": + in_line_comment = False + result.append(character) + else: + result.append(" ") + elif in_block_comment: + if character == "*" and following == "/": + in_block_comment = False + result.extend((" ", " ")) + index += 1 + else: + result.append("\n" if character in "\r\n" else " ") + elif in_string: + result.append(character) + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + elif character == '"': + in_string = True + result.append(character) + elif character == "/" and following == "/": + in_line_comment = True + result.extend((" ", " ")) + index += 1 + elif character == "/" and following == "*": + in_block_comment = True + result.extend((" ", " ")) + index += 1 + else: + result.append(character) + index += 1 + + return re.sub(r",\s*([}\]])", r"\1", "".join(result)) + + +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..ab53c17 --- /dev/null +++ b/repo_policy_sync/operations/replace_regex.py @@ -0,0 +1,78 @@ +# ******************************************************************************* +# 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 EnsureOperation, ReplaceRegex +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_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_change(self, path: Path, operation: EnsureOperation) -> str | None: + assert isinstance(operation, ReplaceRegex) + _validate_target(path, operation) + if not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + return ( + "replace matching text" + if re.sub(operation.pattern, operation.replacement, text) != text + else None + ) + + def apply(self, path: Path, operation: EnsureOperation) -> None: + assert isinstance(operation, ReplaceRegex) + _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..40c9e4b --- /dev/null +++ b/repo_policy_sync/operations/synchronize_bazel_dependencies.py @@ -0,0 +1,479 @@ +# ******************************************************************************* +# 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 +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, +) + +# Keep the complete bazel_dep text so names and versions can be located exactly. +_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) +_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*([\"'])([^\"']+)\1") +_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") +_GIT_OVERRIDE_CALL = re.compile(r"git_override\s*\((.*?)\)", re.DOTALL) +_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") +# The boundaries prevent changing names such as score_process_description twice. +_LEGACY_BUILD_REFERENCE = re.compile(r"(? 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 + ) -> tuple[Change, ...]: + assert isinstance(operation, SynchronizeBazelDependencies) + module_path = root / operation.module_file + # Validation happens while collecting replacements, even when no text changes. + replacements = _module_replacements(module_path, operation) + changes: list[Change] = [] + if replacements: + changes.append( + Change( + operation.module_file, + "synchronize SCORE Bazel dependency versions and module names", + operation.rationale, + ) + ) + for path in _build_files(root, operation): + if _LEGACY_BUILD_REFERENCE.search(path.read_text(encoding="utf-8")): + changes.append( + Change( + path.relative_to(root), + "replace 'score_process' with 'score_process_description'", + operation.rationale, + ) + ) + return tuple(changes) + + def apply(self, root: Path, operation: EnsureOperation) -> None: + assert isinstance(operation, SynchronizeBazelDependencies) + module_path = root / operation.module_file + replacements = _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") + for path in _build_files(root, operation): + text = path.read_text(encoding="utf-8") + replaced = _LEGACY_BUILD_REFERENCE.sub("score_process_description", text) + 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 +) -> list[tuple[int, int, str]]: + 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 + + +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 + 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 call in _GIT_OVERRIDE_CALL.finditer(text): + module_name_matches = list(_MODULE_NAME_ARGUMENT.finditer(call.group(1))) + 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(call.group(1))) + 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(call.group(1))) + 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 + call_content_start = call.start(1) + locations[module_name] = _GitOverrideLocation( + module_name=module_name, + commit=commit_match.group(2), + module_name_start=call_content_start + module_name_match.start(2), + module_name_end=call_content_start + module_name_match.end(2), + commit_start=call_content_start + commit_match.start(2), + commit_end=call_content_start + commit_match.end(2), + remote=remote_match.group(2) if remote_match else None, + remote_start=(call_content_start + remote_match.start(2)) + if remote_match + else None, + remote_end=(call_content_start + remote_match.end(2)) + if remote_match + else None, + remote_insertion=call.end(1), + remote_insertion_prefix="" if call.group(1).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 call in _BAZEL_DEP_CALL.finditer(text): + name_matches = list(_NAME_ARGUMENT.finditer(call.group(1))) + 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(call.group(1))) + 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}" + ) + call_content_start = call.start(1) + locations[name] = _DependencyLocation( + name=name, + version=version, + name_start=call_content_start + name_match.start(2), + name_end=call_content_start + name_match.end(2), + version_start=call_content_start + version_match.start(2), + version_end=call_content_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. + return tuple( + path + for path in sorted(root.rglob("*")) + if path.is_file() + and path.name in operation.build_file_names + and ".git" not in path.relative_to(root).parts + ) 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..25a2854 --- /dev/null +++ b/repo_policy_sync/operations/synchronize_devcontainer_version.py @@ -0,0 +1,191 @@ +# ******************************************************************************* +# 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 ..errors import RepoPolicySyncError +from ..models import Change, EnsureOperation, SynchronizeDevcontainerVersion +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, +) + +_NUMERIC_VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") +_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) +_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 + ) -> 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) -> 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 + 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 + if not path.is_file(): + raise RepoPolicySyncError(f"{operation.module_file} must exist") + text = path.read_text(encoding="utf-8") + calls = [] + for match in _BAZEL_DEP_CALL.finditer(text): + name_matches = list(_NAME_ARGUMENT.finditer(match.group(1))) + 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(match) + 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(calls[0].group(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].start(1) + version_match.start(2) + end = calls[0].start(1) + 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..573d41d --- /dev/null +++ b/repo_policy_sync/operations/synchronize_file.py @@ -0,0 +1,324 @@ +# ******************************************************************************* +# 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 EnsureOperation, SynchronizeFile +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_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_change(self, path: Path, operation: EnsureOperation) -> str | None: + assert isinstance(operation, SynchronizeFile) + _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: + return "synchronize contents and make executable" + if content_changed: + return "add file" if not path.exists() else "synchronize contents" + if executable_changed: + return "make executable" + return None + + def apply(self, path: Path, operation: EnsureOperation) -> None: + assert isinstance(operation, SynchronizeFile) + _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) + # The shared build workflow is intentionally unprivileged. Existing + # top-level write permissions would apply to every job in this workflow. + merged = _remove_top_level_section(merged, "permissions") + + if rules and not any( + re.search(re.escape(workflow) + r"@", merged) for workflow, _ in rules + ): + merged = _append_workflow_job(merged, source, rules) + return merged + + +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 + return ( + existing[:start] + + source[source_section[0] : source_section[1]] + + 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(1) + for match in re.finditer( + r"(?m)^ ([^\s#][^:\n]*):[^\n]*(?:\n|$)", + 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 _job_name(job_block: str) -> str | None: + match = re.match(r" ([^\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: + job_lines = list( + re.finditer(r"(?m)^ ([^\s#][^:\n]*):[^\n]*(?:\n|$)", 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 block + return None + + +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/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/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..a4077f5 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/policy.yml @@ -0,0 +1,32 @@ +# ******************************************************************************* +# 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 + copyright_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..609b724 --- /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: '(?:tools|\.devcontainer)/run_tool\.sh' + replacement: .devcontainer/run-tool + rationale: Point pre-commit hooks at the standard SCORE tool launcher. + - type: ensure_no_such_file + path: tools/run_tool.sh + rationale: Replace the legacy tool launcher with the standard SCORE devcontainer launcher. + - type: ensure_no_such_file + path: .devcontainer/run_tool.sh + rationale: Replace the previous launcher path with the standard hyphenated path. +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-standardization/run-tool b/repo_policy_sync/policies/score-devcontainer-standardization/run-tool new file mode 100755 index 0000000..7821981 --- /dev/null +++ b/repo_policy_sync/policies/score-devcontainer-standardization/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-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..3d963ed --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/after/.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/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..900aab9 --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/already-matching/after/.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/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..3d963ed --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/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/docs.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml new file mode 100644 index 0000000..cb82203 --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml @@ -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 +# ******************************************************************************* +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 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..3d963ed --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/legacy-workflow/after/.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/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..26933ad --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/legacy-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 +# ******************************************************************************* +name: Documentation CI +on: + pull_request: + push: + branches: + - main + tags: + - "v*" + release: + types: [published] + merge_group: + types: [checks_requested] +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 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..3d963ed --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/after/.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/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..6e5438b --- /dev/null +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/newer-workflow/after/.github/workflows/docs.yml @@ -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 +# ******************************************************************************* +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@v0.0.4 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..e091e81 --- /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: + path: .github/workflows/docs.yml + pattern: '(?m)^\s*uses:\s+eclipse-score/cicd-workflows/\.github/workflows/docs\.yml@' +ensure: + - type: synchronize_file + path: .github/workflows/docs.yml + source: docs.yml + preserve_workflow_content: true + preserve_reusable_workflow_refs: + - workflow: eclipse-score/cicd-workflows/.github/workflows/docs.yml + minimum_version: 0.0.3 + rationale: Use the documented unprivileged documentation build workflow. + - type: synchronize_file + path: .github/workflows/docs-publish.yml + source: docs-publish.yml + preserve_workflow_content: true + preserve_reusable_workflow_refs: + - workflow: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml + minimum_version: 0.0.3 + rationale: Publish only successful documentation artifacts with the documented 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..eecb884 --- /dev/null +++ b/repo_policy_sync/runner.py @@ -0,0 +1,978 @@ +# ******************************************************************************* +# 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 + elif outcome.status == "pull-request-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: + 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_compliant_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_compliant_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/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..d093dfb --- /dev/null +++ b/repo_policy_sync/tests/test_cli.py @@ -0,0 +1,436 @@ +# ******************************************************************************* +# 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 + + +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", + [ + (("--org", "eclipse-score", "--recreate"), "--recreate requires --apply"), + ( + ("--org", "eclipse-score", "--apply", "--recreate", "--policy", "example"), + "exactly one --repo", + ), + ( + ("--org", "eclipse-score", "--apply", "--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( + ( + "--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( + ( + "--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( + ( + "--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_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( + ( + "--org", + "eclipse-score", + "--repo", + "reference_integration", + "--policy", + "minimum-bazel-version", + "--apply", + "--recreate", + "--quiet", + ) + ) + == 0 + ) + + assert loaded_paths == [ + BUNDLED_POLICY_DIRECTORY / "minimum-bazel-version" / "policy.yml" + ] + assert observed["recreate"] is True + + +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( + ( + "--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( + ( + "--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( + ( + "--org", + "eclipse-score", + "--repo", + "reference_integration", + "--policy-dir", + "repo_policy_sync/policies", + "--policy", + "minimum-bazel-version", + "--no-apply", + "--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"] +apply = true +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( + ( + "--config", + str(config_path), + "--org", + "cli-org", + "--repo", + "cli-repo", + "--no-apply", + "--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 False + 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(("--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(("--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( + ("--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( + ( + "--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( + ("--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( + ("--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 ( + help_text.index("Typical:") + < help_text.index("Rare:") + < help_text.index("Debugging only:") + ) + assert ( + help_text.index("--apply") + < help_text.index("--recreate") + < help_text.index("--cache-dir") + ) diff --git a/repo_policy_sync/tests/test_config.py b/repo_policy_sync/tests/test_config.py new file mode 100644 index 0000000..41fd57d --- /dev/null +++ b/repo_policy_sync/tests/test_config.py @@ -0,0 +1,97 @@ +# ******************************************************************************* +# 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"] +apply = true +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.apply is True + 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..93a3d72 --- /dev/null +++ b/repo_policy_sync/tests/test_engine.py @@ -0,0 +1,837 @@ +# ******************************************************************************* +# 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, + BazelCondition, + Change, + EnsureLine, + 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_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 SCORE 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 SCORE 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_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_workflow_inserts_missing_name_and_preserves_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_workflow_rejects_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_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): + calls.append((tuple(command), cwd)) + assert capture_output is True + assert text is True + + 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): + assert command == ("bazel", "mod", "deps") + assert cwd == tmp_path + assert capture_output is True + assert text is True + 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_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): + calls.append(tuple(command)) + assert capture_output is True + assert text is True + + 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 diff --git a/repo_policy_sync/tests/test_github.py b/repo_policy_sync/tests/test_github.py new file mode 100644 index 0000000..6a17283 --- /dev/null +++ b/repo_policy_sync/tests/test_github.py @@ -0,0 +1,1084 @@ +# ******************************************************************************* +# 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") + 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", "--all-files"], 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") + 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") + 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") + 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", + } + ] + ) + + 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..5e5a3ef --- /dev/null +++ b/repo_policy_sync/tests/test_runner.py @@ -0,0 +1,1057 @@ +# ******************************************************************************* +# 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_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/uv.lock b/uv.lock index bd25d70..8f1127b 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" @@ -46,6 +55,96 @@ 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 = "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 +170,123 @@ 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 = "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 = "." } +version = "0.0.0" +source = { editable = "." } dependencies = [ { name = "bazel-runfiles" }, + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "bazel-runfiles", specifier = "==1.3.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" }, ] From 34d577a50485b98188a1f2ca8e752c8b1f6a309e Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 09:59:05 +0200 Subject: [PATCH 02/12] fix(repo-policy-sync): generalize policy operations --- .../docs/reference/policy-format.md | 12 ++- repo_policy_sync/models.py | 6 +- repo_policy_sync/operations/__init__.py | 60 +++++---------- .../operations/ensure_bazel_dependency.py | 14 +++- repo_policy_sync/operations/ensure_line.py | 38 +++++++--- .../operations/ensure_minimum_version.py | 30 ++++++-- .../operations/ensure_no_such_file.py | 30 ++++++-- .../operations/migrate_devcontainer_json.py | 69 ++++++++++------- repo_policy_sync/operations/replace_regex.py | 26 +++++-- .../synchronize_bazel_dependencies.py | 69 ++++++++++++++--- .../synchronize_devcontainer_version.py | 14 +++- .../operations/synchronize_file.py | 36 ++++++--- .../copyright-header | 12 +++ .../policy.yml | 4 +- repo_policy_sync/tests/test_engine.py | 75 ++++++++++++++++++- 15 files changed, 367 insertions(+), 128 deletions(-) create mode 100644 repo_policy_sync/policies/score-devcontainer-dockerfile-migration/copyright-header diff --git a/repo_policy_sync/docs/reference/policy-format.md b/repo_policy_sync/docs/reference/policy-format.md index 488256f..0f4b8cf 100644 --- a/repo_policy_sync/docs/reference/policy-format.md +++ b/repo_policy_sync/docs/reference/policy-format.md @@ -299,13 +299,17 @@ declaration. Overrides for absent optional dependencies are skipped. destination: .devcontainer/devcontainer.json dockerfile: .devcontainer/Dockerfile image: ghcr.io/eclipse-score/devcontainer - copyright_organization: eclipse-score + 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_organization` is -set, the standard Eclipse Foundation copyright header is added only when the -policy is applied to that organization. +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/models.py b/repo_policy_sync/models.py index 877780c..1c2d72d 100644 --- a/repo_policy_sync/models.py +++ b/repo_policy_sync/models.py @@ -157,14 +157,16 @@ class SynchronizeFile: @dataclass(frozen=True) class MigrateDevcontainerJson: - """Replace a SCORE image-based devcontainer config with a Dockerfile.""" + """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_organization: str | None = None + copyright_header: str | None = None + copyright_header_organization: str | None = None EnsureOperation = ( diff --git a/repo_policy_sync/operations/__init__.py b/repo_policy_sync/operations/__init__.py index 67f06e8..7ae139d 100644 --- a/repo_policy_sync/operations/__init__.py +++ b/repo_policy_sync/operations/__init__.py @@ -18,14 +18,7 @@ from pathlib import Path from typing import Any, Protocol -from ..models import ( - Change, - EnsureBazelDependency, - EnsureOperation, - MigrateDevcontainerJson, - SynchronizeBazelDependencies, - SynchronizeDevcontainerVersion, -) +from ..models import Change, EnsureOperation from .ensure_bazel_dependency import EnsureBazelDependencyOperation from .ensure_line import EnsureLineOperation from .ensure_minimum_version import EnsureMinimumVersionOperation @@ -39,12 +32,25 @@ class OperationHandler(Protocol): operation_type: str + operation_class: type[Any] def parse(self, raw: dict[str, Any], source: Path) -> EnsureOperation: ... - def describe_change(self, path: Path, operation: EnsureOperation) -> str | None: ... + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: ... - def apply(self, path: Path, operation: EnsureOperation) -> None: ... + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: ... _HANDLERS: tuple[OperationHandler, ...] = ( @@ -85,21 +91,8 @@ def describe_changes( ) -> tuple[Change, ...]: """Describe every path an operation would change.""" - handler = _handler_for(operation) - if isinstance(operation, MigrateDevcontainerJson): - return handler.describe_changes(root, operation, organization=organization) # type: ignore[attr-defined] - if isinstance(operation, EnsureBazelDependency): - return handler.describe_changes(root, operation) # type: ignore[attr-defined] - if isinstance(operation, SynchronizeDevcontainerVersion): - return handler.describe_changes(root, operation) # type: ignore[attr-defined] - if isinstance(operation, SynchronizeBazelDependencies): - return handler.describe_changes(root, operation) # type: ignore[attr-defined] - path = root / operation.path - description = handler.describe_change(path, operation) - return ( - (Change(operation.path, description, operation.rationale),) - if description - else () + return _handler_for(operation).describe_changes( + root, operation, organization=organization ) @@ -108,24 +101,11 @@ def apply( ) -> None: """Apply one operation from a repository root.""" - handler = _handler_for(operation) - if isinstance(operation, MigrateDevcontainerJson): - handler.apply(root, operation, organization=organization) # type: ignore[attr-defined] - return - if isinstance(operation, EnsureBazelDependency): - handler.apply(root, operation) # type: ignore[attr-defined] - return - if isinstance(operation, SynchronizeDevcontainerVersion): - handler.apply(root, operation) - return - if isinstance(operation, SynchronizeBazelDependencies): - handler.apply(root, operation) # type: ignore[attr-defined] - return - handler.apply(root / operation.path, operation) + _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): # type: ignore[attr-defined] + 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/ensure_bazel_dependency.py b/repo_policy_sync/operations/ensure_bazel_dependency.py index 0463315..50ef4ba 100644 --- a/repo_policy_sync/operations/ensure_bazel_dependency.py +++ b/repo_policy_sync/operations/ensure_bazel_dependency.py @@ -63,7 +63,11 @@ def parse(self, raw: dict[str, Any], source: Path) -> EnsureBazelDependency: ) def describe_changes( - self, root: Path, operation: EnsureOperation + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, ) -> tuple[Change, ...]: assert isinstance(operation, EnsureBazelDependency) version = _docker_version(root, operation) @@ -78,7 +82,13 @@ def describe_changes( ), ) - def apply(self, root: Path, operation: EnsureOperation) -> None: + 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: diff --git a/repo_policy_sync/operations/ensure_line.py b/repo_policy_sync/operations/ensure_line.py index 0171c23..95d08ea 100644 --- a/repo_policy_sync/operations/ensure_line.py +++ b/repo_policy_sync/operations/ensure_line.py @@ -18,7 +18,7 @@ from typing import Any from ..errors import RepoPolicySyncError -from ..models import EnsureLine, EnsureOperation +from ..models import Change, EnsureLine, EnsureOperation from ._validation import ( expect_keys, optional_string, @@ -57,13 +57,20 @@ def parse(self, raw: dict[str, Any], source: Path) -> EnsureLine: rationale=optional_string(raw, "rationale", source), ) - def describe_change(self, path: Path, operation: EnsureOperation) -> str | None: + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: assert isinstance(operation, EnsureLine) + path = root / operation.path _validate_target(path, operation) lines = _read_lines(path) normalized = _normalized_lines(lines, operation) if normalized == lines: - return None + return () desired_count = sum(line == operation.line for line in lines) obsolete = [ line @@ -71,15 +78,24 @@ def describe_change(self, path: Path, operation: EnsureOperation) -> str | None: if line != operation.line and _matches_replacement(line, operation) ] if obsolete: - return f"replace {', '.join(repr(line) for line in dict.fromkeys(obsolete))} with {operation.line!r}" - if desired_count == 0: - return f"add {operation.line!r}" - if desired_count > 1: - return f"remove duplicate {operation.line!r} entries" - return f"normalize {operation.line!r}" - - def apply(self, path: Path, operation: EnsureOperation) -> None: + 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_target(path, operation) normalized = _normalized_lines(_read_lines(path), operation) if normalized == _read_lines(path): diff --git a/repo_policy_sync/operations/ensure_minimum_version.py b/repo_policy_sync/operations/ensure_minimum_version.py index 69895be..ecf6211 100644 --- a/repo_policy_sync/operations/ensure_minimum_version.py +++ b/repo_policy_sync/operations/ensure_minimum_version.py @@ -18,7 +18,7 @@ from typing import Any from ..errors import PolicyError, RepoPolicySyncError -from ..models import EnsureMinimumVersion, EnsureOperation +from ..models import Change, EnsureMinimumVersion, EnsureOperation from ._validation import ( expect_keys, optional_string, @@ -46,15 +46,35 @@ def parse(self, raw: dict[str, Any], source: Path) -> EnsureMinimumVersion: optional_string(raw, "rationale", source), ) - def describe_change(self, path: Path, operation: EnsureOperation) -> str | None: + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: assert isinstance(operation, EnsureMinimumVersion) + path = root / operation.path current_version = _read_version(path, operation) if current_version is None or current_version >= _required_version(operation): - return None - return f"upgrade from {path.read_text(encoding='utf-8').strip()!r} to {operation.minimum_version!r}" + 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, path: Path, operation: EnsureOperation) -> None: + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: assert isinstance(operation, EnsureMinimumVersion) + path = root / operation.path current_version = _read_version(path, operation) if current_version is None or current_version >= _required_version(operation): return diff --git a/repo_policy_sync/operations/ensure_no_such_file.py b/repo_policy_sync/operations/ensure_no_such_file.py index 9c9803c..a7c7188 100644 --- a/repo_policy_sync/operations/ensure_no_such_file.py +++ b/repo_policy_sync/operations/ensure_no_such_file.py @@ -17,7 +17,7 @@ from typing import Any from ..errors import RepoPolicySyncError -from ..models import EnsureNoSuchFile, EnsureOperation +from ..models import Change, EnsureNoSuchFile, EnsureOperation from ._validation import ( expect_keys, optional_string, @@ -37,16 +37,34 @@ def parse(self, raw: dict[str, Any], source: Path) -> EnsureNoSuchFile: optional_string(raw, "rationale", source), ) - def describe_change(self, path: Path, operation: EnsureOperation) -> str | None: + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, EnsureNoSuchFile) + path = root / operation.path if path.is_dir(): - assert isinstance(operation, EnsureNoSuchFile) raise RepoPolicySyncError(f"refusing to remove directory {operation.path}") - return "remove file" if path.exists() else None + return ( + (Change(operation.path, "remove file", operation.rationale),) + if path.exists() + else () + ) - def apply(self, path: Path, operation: EnsureOperation) -> None: + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, EnsureNoSuchFile) + path = root / operation.path if not path.exists(): return if path.is_dir(): - assert isinstance(operation, EnsureNoSuchFile) 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 index 082ac81..5eeb73f 100644 --- a/repo_policy_sync/operations/migrate_devcontainer_json.py +++ b/repo_policy_sync/operations/migrate_devcontainer_json.py @@ -21,7 +21,7 @@ from pathlib import Path from typing import Any -from ..errors import RepoPolicySyncError +from ..errors import PolicyError, RepoPolicySyncError from ..models import Change, EnsureOperation, MigrateDevcontainerJson from ._validation import ( expect_keys, @@ -32,22 +32,6 @@ ) _VERSION = re.compile(r"v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z") -_DOCKERFILE_COMMENT = ( - "# Use Dockerfile to get dependabot version bumps after new image is released" -) -_ECLIPSE_COPYRIGHT = """# ******************************************************************************* -# 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 -# ******************************************************************************* -""" _IMAGE_PROPERTY = re.compile( r'(?m)^(?P[ \t]*)"image"\s*:\s*"(?P[^"]+)"' r"(?P,?)(?P[ \t]*(?://[^\r\n]*)?(?:\r?\n|$))" @@ -67,11 +51,39 @@ def parse(self, raw: dict[str, Any], source: Path) -> MigrateDevcontainerJson: "destination", "dockerfile", "image", + "dockerfile_comment", "rationale", - "copyright_organization", + "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) @@ -84,10 +96,10 @@ def parse(self, raw: dict[str, Any], source: Path) -> MigrateDevcontainerJson: 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_organization=optional_string( - raw, "copyright_organization", source - ), + copyright_header=copyright_header, + copyright_header_organization=copyright_header_organization, ) def describe_changes( @@ -151,7 +163,11 @@ def describe_changes( return tuple(changes) def apply( - self, root: Path, operation: EnsureOperation, *, organization: str | None = None + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, ) -> None: assert isinstance(operation, MigrateDevcontainerJson) _, source = _find_source(root, operation) @@ -250,12 +266,15 @@ def _migration_contents( ) destination_contents = text[: match.start()] + replacement + text[match.end() :] copyright_header = ( - _ECLIPSE_COPYRIGHT if operation.copyright_organization == organization else "" + operation.copyright_header + if operation.copyright_header_organization == organization + else "" ) prefix = f"{copyright_header}\n" if copyright_header else "" - dockerfile_contents = ( - f"{prefix}{_DOCKERFILE_COMMENT}\nFROM {operation.image}:{tag}\n" + 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 diff --git a/repo_policy_sync/operations/replace_regex.py b/repo_policy_sync/operations/replace_regex.py index ab53c17..16fd5ca 100644 --- a/repo_policy_sync/operations/replace_regex.py +++ b/repo_policy_sync/operations/replace_regex.py @@ -18,7 +18,7 @@ from typing import Any from ..errors import PolicyError, RepoPolicySyncError -from ..models import EnsureOperation, ReplaceRegex +from ..models import Change, EnsureOperation, ReplaceRegex from ._validation import ( expect_keys, optional_string, @@ -50,20 +50,34 @@ def parse(self, raw: dict[str, Any], source: Path) -> ReplaceRegex: optional_string(raw, "rationale", source), ) - def describe_change(self, path: Path, operation: EnsureOperation) -> str | None: + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: assert isinstance(operation, ReplaceRegex) + path = root / operation.path _validate_target(path, operation) if not path.is_file(): - return None + return () text = path.read_text(encoding="utf-8") return ( - "replace matching text" + (Change(operation.path, "replace matching text", operation.rationale),) if re.sub(operation.pattern, operation.replacement, text) != text - else None + else () ) - def apply(self, path: Path, operation: EnsureOperation) -> None: + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: assert isinstance(operation, ReplaceRegex) + path = root / operation.path _validate_target(path, operation) if not path.is_file(): return diff --git a/repo_policy_sync/operations/synchronize_bazel_dependencies.py b/repo_policy_sync/operations/synchronize_bazel_dependencies.py index 40c9e4b..02fe6cc 100644 --- a/repo_policy_sync/operations/synchronize_bazel_dependencies.py +++ b/repo_policy_sync/operations/synchronize_bazel_dependencies.py @@ -44,8 +44,6 @@ _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") -# The boundaries prevent changing names such as score_process_description twice. -_LEGACY_BUILD_REFERENCE = re.compile(r"(? SynchronizeBazelDependenci ) def describe_changes( - self, root: Path, operation: EnsureOperation + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, ) -> tuple[Change, ...]: assert isinstance(operation, SynchronizeBazelDependencies) module_path = root / operation.module_file # Validation happens while collecting replacements, even when no text changes. - replacements = _module_replacements(module_path, operation) + replacements, locations = _module_replacements(module_path, operation) changes: list[Change] = [] if replacements: changes.append( Change( operation.module_file, - "synchronize SCORE Bazel dependency versions and module names", + "synchronize Bazel dependency versions and module names", operation.rationale, ) ) + build_pairs = _build_reference_pairs(operation, locations) for path in _build_files(root, operation): - if _LEGACY_BUILD_REFERENCE.search(path.read_text(encoding="utf-8")): + text = path.read_text(encoding="utf-8") + if _replace_build_references(text, build_pairs) != text: changes.append( Change( path.relative_to(root), - "replace 'score_process' with 'score_process_description'", + _build_reference_description(build_pairs), operation.rationale, ) ) return tuple(changes) - def apply(self, root: Path, operation: EnsureOperation) -> None: + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: assert isinstance(operation, SynchronizeBazelDependencies) module_path = root / operation.module_file - replacements = _module_replacements(module_path, operation) + 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 = _LEGACY_BUILD_REFERENCE.sub("score_process_description", text) + replaced = _replace_build_references(text, build_pairs) if replaced != text: path.write_text(replaced, encoding="utf-8") @@ -221,7 +232,7 @@ def _dependency_names(dependency: BazelDependencyUpdate) -> tuple[str, ...]: def _module_replacements( path: Path, operation: SynchronizeBazelDependencies -) -> list[tuple[int, int, str]]: +) -> 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") @@ -252,7 +263,41 @@ def _module_replacements( (location.version_start, location.version_end, dependency.version) ) replacements.extend(_git_override_replacements(text, operation, locations)) - return replacements + 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)) + pattern = re.compile( + r"(?" + + "|".join(re.escape(name) for name in names) + + r")(?![A-Za-z0-9_])" + ) + return pattern.sub(lambda match: 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( diff --git a/repo_policy_sync/operations/synchronize_devcontainer_version.py b/repo_policy_sync/operations/synchronize_devcontainer_version.py index 25a2854..837ba61 100644 --- a/repo_policy_sync/operations/synchronize_devcontainer_version.py +++ b/repo_policy_sync/operations/synchronize_devcontainer_version.py @@ -69,7 +69,11 @@ def parse( ) def describe_changes( - self, root: Path, operation: EnsureOperation + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, ) -> tuple[Change, ...]: assert isinstance(operation, SynchronizeDevcontainerVersion) docker, module = _locations(root, operation) @@ -86,7 +90,13 @@ def describe_changes( ), ) - def apply(self, root: Path, operation: EnsureOperation) -> None: + 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: diff --git a/repo_policy_sync/operations/synchronize_file.py b/repo_policy_sync/operations/synchronize_file.py index 573d41d..e8f2f3f 100644 --- a/repo_policy_sync/operations/synchronize_file.py +++ b/repo_policy_sync/operations/synchronize_file.py @@ -21,7 +21,7 @@ from typing import Any from ..errors import PolicyError, RepoPolicySyncError -from ..models import EnsureOperation, SynchronizeFile +from ..models import Change, EnsureOperation, SynchronizeFile from ._validation import ( expect_keys, optional_string, @@ -86,8 +86,15 @@ def parse(self, raw: dict[str, Any], source: Path) -> SynchronizeFile: rationale=optional_string(raw, "rationale", source), ) - def describe_change(self, path: Path, operation: EnsureOperation) -> str | None: + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: assert isinstance(operation, SynchronizeFile) + path = root / operation.path _validate_target(path, operation) content_changed = not path.is_file() or ( _desired_contents(path, operation) != path.read_text(encoding="utf-8") @@ -96,15 +103,24 @@ def describe_change(self, path: Path, operation: EnsureOperation) -> str | None: operation.executable and path.is_file() and not _is_executable(path) ) if content_changed and executable_changed: - return "synchronize contents and make executable" - if content_changed: - return "add file" if not path.exists() else "synchronize contents" - if executable_changed: - return "make executable" - return None - - def apply(self, path: Path, operation: EnsureOperation) -> None: + 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_target(path, operation) desired_contents = _desired_contents(path, operation) if not path.is_file() or path.read_text(encoding="utf-8") != desired_contents: 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/policy.yml b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/policy.yml index a4077f5..a6bb5b2 100644 --- a/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/policy.yml +++ b/repo_policy_sync/policies/score-devcontainer-dockerfile-migration/policy.yml @@ -28,5 +28,7 @@ ensure: destination: .devcontainer/devcontainer.json dockerfile: .devcontainer/Dockerfile image: ghcr.io/eclipse-score/devcontainer - copyright_organization: eclipse-score + 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/tests/test_engine.py b/repo_policy_sync/tests/test_engine.py index 93a3d72..890385a 100644 --- a/repo_policy_sync/tests/test_engine.py +++ b/repo_policy_sync/tests/test_engine.py @@ -298,7 +298,7 @@ def test_synchronize_bazel_dependencies_adds_and_updates_git_override( assert evaluation.changes == ( Change( Path("MODULE.bazel"), - "synchronize SCORE Bazel dependency versions and module names", + "synchronize Bazel dependency versions and module names", ), ) assert module_file.read_text() == ( @@ -319,7 +319,7 @@ def test_synchronize_bazel_dependencies_adds_and_updates_git_override( assert apply_policy(tmp_path, policy).changes == ( Change( Path("MODULE.bazel"), - "synchronize SCORE Bazel dependency versions and module names", + "synchronize Bazel dependency versions and module names", ), ) assert "old-commit" not in module_file.read_text() @@ -336,6 +336,77 @@ def test_synchronize_bazel_dependencies_adds_and_updates_git_override( ) +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: From 1ee22638b0eeb8b68320cee882a30e0e04f70362 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 10:11:18 +0200 Subject: [PATCH 03/12] fix(repo-policy-sync): address review feedback --- repo_policy_sync/bazel.py | 186 ++++++++++++++++++ repo_policy_sync/docs/how-to/run-a-policy.md | 1 + repo_policy_sync/engine.py | 23 +-- .../operations/ensure_bazel_dependency.py | 16 +- .../operations/ensure_no_such_file.py | 8 +- .../synchronize_bazel_dependencies.py | 79 ++++---- .../synchronize_devcontainer_version.py | 20 +- .../operations/synchronize_file.py | 79 +++++++- repo_policy_sync/tests/test_engine.py | 142 +++++++++++++ 9 files changed, 470 insertions(+), 84 deletions(-) diff --git a/repo_policy_sync/bazel.py b/repo_policy_sync/bazel.py index 2072441..111c718 100644 --- a/repo_policy_sync/bazel.py +++ b/repo_policy_sync/bazel.py @@ -16,11 +16,32 @@ from __future__ import annotations import re +from dataclasses import dataclass from .models import BazelDependencyCondition BazelVersion = tuple[int, int, int] + +@dataclass(frozen=True) +class StarlarkCall: + """A function call found outside Starlark strings and comments.""" + + start: int + body_start: int + body_end: int + + +@dataclass(frozen=True) +class StarlarkStringArgument: + """A quoted keyword argument and its value span in the source text.""" + + key: str + value: str + value_start: int + value_end: 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*" @@ -35,6 +56,171 @@ def parse_bazel_version(value: str) -> BazelVersion | None: return tuple(int(component) for component in match.groups()) if match else None +def find_starlark_calls(text: str, function_name: str) -> tuple[StarlarkCall, ...]: + """Find calls to ``function_name`` while ignoring strings and comments.""" + + masked = _mask_starlark(text) + function = re.compile(rf"\b{re.escape(function_name)}\s*\(") + calls: list[StarlarkCall] = [] + for match in function.finditer(masked): + opening = masked.find("(", match.start(), match.end()) + depth = 0 + for index in range(opening, len(masked)): + character = masked[index] + if character == "(": + depth += 1 + elif character == ")": + depth -= 1 + if depth == 0: + calls.append(StarlarkCall(match.start(), opening + 1, index)) + break + return tuple(calls) + + +def starlark_string_arguments( + text: str, call: StarlarkCall, key: str +) -> tuple[StarlarkStringArgument, ...]: + """Find direct quoted ``key = "value"`` arguments in one call.""" + + arguments: list[StarlarkStringArgument] = [] + index = call.body_start + depth = 0 + while index < call.body_end: + character = text[index] + if character == "#": + index = _skip_comment(text, index) + continue + if character in "'\"": + index = _skip_string(text, index) + continue + if character == "(": + depth += 1 + index += 1 + continue + if character == ")": + depth -= 1 + index += 1 + continue + if depth != 0 or not (character.isalpha() or character == "_"): + index += 1 + continue + + key_start = index + index += 1 + while index < call.body_end and (text[index].isalnum() or text[index] == "_"): + index += 1 + if text[key_start:index] != key: + continue + index = _skip_whitespace_and_comments(text, index) + if index >= call.body_end or text[index] != "=": + continue + index = _skip_whitespace_and_comments(text, index + 1) + if index >= call.body_end or text[index] not in "'\"": + continue + delimiter = _string_delimiter(text, index) + value_start = index + len(delimiter) + value_end = _skip_string(text, index) + content_end = value_end - len(delimiter) + if value_end <= value_start or text[content_end:value_end] != delimiter: + continue + arguments.append( + StarlarkStringArgument( + key, + text[value_start:content_end], + value_start, + content_end, + ) + ) + index = value_end + return tuple(arguments) + + +def _mask_starlark(text: str) -> str: + masked = list(text) + index = 0 + while index < len(text): + character = text[index] + if character == "#": + index = _mask_comment(text, masked, index) + elif character in "'\"": + index = _mask_string(text, masked, index) + else: + index += 1 + return "".join(masked) + + +def _mask_comment(text: str, masked: list[str], start: int) -> int: + index = start + while index < len(text) and text[index] not in "\r\n": + masked[index] = " " + index += 1 + return index + + +def _mask_string(text: str, masked: list[str], start: int) -> int: + delimiter = _string_delimiter(text, start) + for offset in range(len(delimiter)): + if start + offset < len(masked) and text[start + offset] not in "\r\n": + masked[start + offset] = " " + index = start + 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)): + if index + offset < len(masked) and text[index + offset] not in "\r\n": + masked[index + offset] = " " + return index + len(delimiter) + index += 1 + return index + + +def _skip_comment(text: str, start: int) -> int: + index = start + while index < len(text) and text[index] not in "\r\n": + index += 1 + return index + + +def _skip_string(text: str, start: int) -> int: + delimiter = _string_delimiter(text, start) + index = start + len(delimiter) + escaped = False + while index < len(text): + character = text[index] + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif text.startswith(delimiter, index): + return index + len(delimiter) + index += 1 + return index + + +def _string_delimiter(text: str, start: int) -> str: + quote = text[start] + return quote * 3 if text.startswith(quote * 3, start) else quote + + +def _skip_whitespace_and_comments(text: str, start: int) -> int: + index = start + while index < len(text): + if text[index].isspace(): + index += 1 + elif text[index] == "#": + index = _skip_comment(text, index) + else: + break + return index + + def parse_bazel_dependency_condition(value: str) -> BazelDependencyCondition | None: """Parse ``module OP major.minor.patch`` condition syntax.""" diff --git a/repo_policy_sync/docs/how-to/run-a-policy.md b/repo_policy_sync/docs/how-to/run-a-policy.md index e36d122..3e920c7 100644 --- a/repo_policy_sync/docs/how-to/run-a-policy.md +++ b/repo_policy_sync/docs/how-to/run-a-policy.md @@ -71,6 +71,7 @@ policy-owned pull requests: uv run score-repo-policy-sync \ --org eclipse-score \ --repo reference_integration \ + --policy-dir policies \ --policy minimum-bazel-version \ --apply ``` diff --git a/repo_policy_sync/engine.py b/repo_policy_sync/engine.py index de39270..e27b565 100644 --- a/repo_policy_sync/engine.py +++ b/repo_policy_sync/engine.py @@ -20,16 +20,17 @@ import subprocess from pathlib import Path -from .bazel import matches_bazel_dependency_condition, parse_bazel_version +from .bazel import ( + find_starlark_calls, + matches_bazel_dependency_condition, + parse_bazel_version, + starlark_string_arguments, +) from .errors import CommandError, redact_sensitive_text from .models import Change, Evaluation, Policy, SynchronizeBazelDependencies from .operations import apply as apply_operation from .operations import describe_changes -_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) -_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") -_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*[\"']([^\"']+)[\"']") - def evaluate_policy( root: Path, policy: Policy, *, organization: str | None = None @@ -135,13 +136,13 @@ def _matches_bazel_condition(root: Path, policy: Policy) -> bool: return False text = module_file.read_text(encoding="utf-8") dependencies: dict[str, tuple[int, int, int] | None] = {} - for call in _BAZEL_DEP_CALL.finditer(text): - name_match = _NAME_ARGUMENT.search(call.group(1)) - if name_match is None: + for call in find_starlark_calls(text, "bazel_dep"): + name_matches = starlark_string_arguments(text, call, "name") + if not name_matches: continue - version_match = _VERSION_ARGUMENT.search(call.group(1)) - dependencies[name_match.group(1)] = ( - parse_bazel_version(version_match.group(1)) if version_match else None + version_matches = starlark_string_arguments(text, call, "version") + dependencies[name_matches[0].value] = ( + parse_bazel_version(version_matches[0].value) if version_matches else None ) # A policy can require a complete set and also accept one of several names. dependency_names = set(dependencies) diff --git a/repo_policy_sync/operations/ensure_bazel_dependency.py b/repo_policy_sync/operations/ensure_bazel_dependency.py index 50ef4ba..3a29eb3 100644 --- a/repo_policy_sync/operations/ensure_bazel_dependency.py +++ b/repo_policy_sync/operations/ensure_bazel_dependency.py @@ -20,6 +20,7 @@ from pathlib import Path from typing import Any +from ..bazel import find_starlark_calls, starlark_string_arguments from ..errors import RepoPolicySyncError from ..models import Change, EnsureBazelDependency, EnsureOperation from ._validation import ( @@ -30,9 +31,6 @@ ) _NUMERIC_VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") -_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) -_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") -_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") @dataclass(frozen=True) @@ -137,24 +135,24 @@ def _module_dependency( raise RepoPolicySyncError(f"{operation.module_file} must exist") text = path.read_text(encoding="utf-8") calls = [] - for match in _BAZEL_DEP_CALL.finditer(text): - name_matches = list(_NAME_ARGUMENT.finditer(match.group(1))) + for call in find_starlark_calls(text, "bazel_dep"): + name_matches = starlark_string_arguments(text, call, "name") if any( - name_match.group(1) == operation.module_name for name_match in name_matches + name_match.value == 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(match) + calls.append(call) 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(calls[0].group(1))) + version_matches = starlark_string_arguments(text, calls[0], "version") if not version_matches: raise RepoPolicySyncError( f'{operation.module_file} bazel_dep for {operation.module_name!r} must declare version = "X.Y.Z"' @@ -163,7 +161,7 @@ def _module_dependency( raise RepoPolicySyncError( f"{operation.module_file} bazel_dep for {operation.module_name!r} must declare version exactly once" ) - version = version_matches[0].group(2) + version = version_matches[0].value 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}" diff --git a/repo_policy_sync/operations/ensure_no_such_file.py b/repo_policy_sync/operations/ensure_no_such_file.py index a7c7188..695967b 100644 --- a/repo_policy_sync/operations/ensure_no_such_file.py +++ b/repo_policy_sync/operations/ensure_no_such_file.py @@ -46,11 +46,11 @@ def describe_changes( ) -> tuple[Change, ...]: assert isinstance(operation, EnsureNoSuchFile) path = root / operation.path - if path.is_dir(): + if path.is_dir() and not path.is_symlink(): raise RepoPolicySyncError(f"refusing to remove directory {operation.path}") return ( (Change(operation.path, "remove file", operation.rationale),) - if path.exists() + if path.exists() or path.is_symlink() else () ) @@ -63,8 +63,8 @@ def apply( ) -> None: assert isinstance(operation, EnsureNoSuchFile) path = root / operation.path - if not path.exists(): + if not path.exists() and not path.is_symlink(): return - if path.is_dir(): + if path.is_dir() and not path.is_symlink(): raise RepoPolicySyncError(f"refusing to remove directory {operation.path}") path.unlink() diff --git a/repo_policy_sync/operations/synchronize_bazel_dependencies.py b/repo_policy_sync/operations/synchronize_bazel_dependencies.py index 02fe6cc..b837008 100644 --- a/repo_policy_sync/operations/synchronize_bazel_dependencies.py +++ b/repo_policy_sync/operations/synchronize_bazel_dependencies.py @@ -20,7 +20,11 @@ from pathlib import Path from typing import Any -from ..bazel import parse_bazel_version +from ..bazel import ( + find_starlark_calls, + parse_bazel_version, + starlark_string_arguments, +) from ..errors import PolicyError, RepoPolicySyncError from ..models import ( BazelDependencyUpdate, @@ -36,15 +40,6 @@ string_list, ) -# Keep the complete bazel_dep text so names and versions can be located exactly. -_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) -_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*([\"'])([^\"']+)\1") -_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") -_GIT_OVERRIDE_CALL = re.compile(r"git_override\s*\((.*?)\)", re.DOTALL) -_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: @@ -390,56 +385,53 @@ def _git_override_locations( for name in _dependency_names(dependency) } locations: dict[str, _GitOverrideLocation] = {} - for call in _GIT_OVERRIDE_CALL.finditer(text): - module_name_matches = list(_MODULE_NAME_ARGUMENT.finditer(call.group(1))) + for call in find_starlark_calls(text, "git_override"): + module_name_matches = starlark_string_arguments(text, call, "module_name") matching_names = [ - match for match in module_name_matches if match.group(2) in configured_names + match for match in module_name_matches if match.value 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}" + f"for {matching_names[0].value!r}" ) module_name_match = matching_names[0] - module_name = module_name_match.group(2) + module_name = module_name_match.value 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(call.group(1))) + commit_matches = starlark_string_arguments(text, call, "commit") 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(call.group(1))) + remote_matches = starlark_string_arguments(text, call, "remote") 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 - call_content_start = call.start(1) locations[module_name] = _GitOverrideLocation( module_name=module_name, - commit=commit_match.group(2), - module_name_start=call_content_start + module_name_match.start(2), - module_name_end=call_content_start + module_name_match.end(2), - commit_start=call_content_start + commit_match.start(2), - commit_end=call_content_start + commit_match.end(2), - remote=remote_match.group(2) if remote_match else None, - remote_start=(call_content_start + remote_match.start(2)) - if remote_match - else None, - remote_end=(call_content_start + remote_match.end(2)) - if remote_match - else None, - remote_insertion=call.end(1), - remote_insertion_prefix="" if call.group(1).endswith("\n") else "\n", + commit=commit_match.value, + module_name_start=module_name_match.value_start, + module_name_end=module_name_match.value_end, + commit_start=commit_match.value_start, + commit_end=commit_match.value_end, + remote=remote_match.value if remote_match else None, + remote_start=remote_match.value_start if remote_match else None, + remote_end=remote_match.value_end if remote_match else None, + remote_insertion=call.body_end, + remote_insertion_prefix=( + "" if text[call.body_start : call.body_end].endswith("\n") else "\n" + ), ) return locations @@ -453,10 +445,10 @@ def _module_locations( for dependency in operation.dependencies for name in _dependency_names(dependency) } - for call in _BAZEL_DEP_CALL.finditer(text): - name_matches = list(_NAME_ARGUMENT.finditer(call.group(1))) + for call in find_starlark_calls(text, "bazel_dep"): + name_matches = starlark_string_arguments(text, call, "name") matching_names = [ - match for match in name_matches if match.group(2) in configured_names + match for match in name_matches if match.value in configured_names ] if not matching_names: continue @@ -465,34 +457,33 @@ def _module_locations( 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}" + f"{matching_names[0].value!r}" ) name_match = matching_names[0] - name = name_match.group(2) + name = name_match.value 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(call.group(1))) + version_matches = starlark_string_arguments(text, call, "version") 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_text = version_match.value 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}" ) - call_content_start = call.start(1) locations[name] = _DependencyLocation( name=name, version=version, - name_start=call_content_start + name_match.start(2), - name_end=call_content_start + name_match.end(2), - version_start=call_content_start + version_match.start(2), - version_end=call_content_start + version_match.end(2), + name_start=name_match.value_start, + name_end=name_match.value_end, + version_start=version_match.value_start, + version_end=version_match.value_end, ) for dependency in operation.dependencies: # A migration may find either its old name or its new name, but never both. diff --git a/repo_policy_sync/operations/synchronize_devcontainer_version.py b/repo_policy_sync/operations/synchronize_devcontainer_version.py index 837ba61..694860c 100644 --- a/repo_policy_sync/operations/synchronize_devcontainer_version.py +++ b/repo_policy_sync/operations/synchronize_devcontainer_version.py @@ -20,6 +20,7 @@ from pathlib import Path from typing import Any +from ..bazel import find_starlark_calls, starlark_string_arguments from ..errors import RepoPolicySyncError from ..models import Change, EnsureOperation, SynchronizeDevcontainerVersion from ._validation import ( @@ -30,9 +31,6 @@ ) _NUMERIC_VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") -_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) -_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") -_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") @dataclass(frozen=True) @@ -156,22 +154,22 @@ def _module_location( raise RepoPolicySyncError(f"{operation.module_file} must exist") text = path.read_text(encoding="utf-8") calls = [] - for match in _BAZEL_DEP_CALL.finditer(text): - name_matches = list(_NAME_ARGUMENT.finditer(match.group(1))) + for call in find_starlark_calls(text, "bazel_dep"): + name_matches = starlark_string_arguments(text, call, "name") if any( - name_match.group(1) == operation.module_name for name_match in name_matches + name_match.value == 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(match) + calls.append(call) 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(calls[0].group(1))) + version_matches = starlark_string_arguments(text, calls[0], "version") if not version_matches: raise RepoPolicySyncError( f'{operation.module_file} bazel_dep for {operation.module_name!r} must declare version = "X.Y.Z"' @@ -181,14 +179,14 @@ def _module_location( 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_text = version_match.value 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].start(1) + version_match.start(2) - end = calls[0].start(1) + version_match.end(2) + start = version_match.value_start + end = version_match.value_end return _VersionLocation(operation.module_file, text, start, end, version) diff --git a/repo_policy_sync/operations/synchronize_file.py b/repo_policy_sync/operations/synchronize_file.py index e8f2f3f..1ec6d9e 100644 --- a/repo_policy_sync/operations/synchronize_file.py +++ b/repo_policy_sync/operations/synchronize_file.py @@ -217,10 +217,26 @@ def _merge_workflow_content( # top-level write permissions would apply to every job in this workflow. merged = _remove_top_level_section(merged, "permissions") - if rules and not any( - re.search(re.escape(workflow) + r"@", merged) for workflow, _ in rules - ): - merged = _append_workflow_job(merged, source, rules) + if rules: + 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 not None: + existing_jobs = _top_level_section(merged, "jobs") + existing_job = ( + _matching_job_location( + merged[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: + merged = _merge_matching_job_permissions(merged, source, rules) + else: + merged = _append_workflow_job(merged, source, rules) return merged @@ -304,6 +320,41 @@ def _append_workflow_job( 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 + return existing[:insertion] + 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" ([^\s#][^:\n]*):", job_block) return match.group(1) if match is not None else None @@ -313,6 +364,14 @@ 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 = list( re.finditer(r"(?m)^ ([^\s#][^:\n]*):[^\n]*(?:\n|$)", jobs_section) ) @@ -324,7 +383,17 @@ def _matching_job_block( ) block = jobs_section[match.start() : end] if any(re.search(re.escape(workflow) + r"@", block) for workflow, _ in rules): - return block + return match.start(), end + return None + + +def _nested_job_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 diff --git a/repo_policy_sync/tests/test_engine.py b/repo_policy_sync/tests/test_engine.py index 890385a..c078f24 100644 --- a/repo_policy_sync/tests/test_engine.py +++ b/repo_policy_sync/tests/test_engine.py @@ -24,6 +24,7 @@ BazelDependencyUpdate, BazelCondition, Change, + EnsureBazelDependency, EnsureLine, EnsureMinimumVersion, EnsureNoSuchFile, @@ -77,6 +78,19 @@ def test_policy_does_not_apply_without_direct_dependency(tmp_path: Path) -> None assert evaluation.changes == () +def test_bazel_conditions_ignore_commented_dependencies(tmp_path: Path) -> None: + (tmp_path / "MODULE.bazel").write_text( + '# bazel_dep(name = "score_docs_as_code", version = "1.0")\n' + 'module(name = "example")\n' + "note = \"bazel_dep(name = 'score_docs_as_code', version = '1.0')\"\n" + ) + + evaluation = evaluate_policy(tmp_path, _policy()) + + assert not evaluation.applies + assert evaluation.changes == () + + 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") @@ -138,6 +152,24 @@ def test_ensure_no_such_file_refuses_a_directory_during_evaluation( evaluate_policy(tmp_path, _policy()) +def test_ensure_no_such_file_removes_a_dangling_symlink(tmp_path: Path) -> None: + link = tmp_path / "obsolete" + link.symlink_to("missing") + policy = Policy( + "example", + "Example", + None, + None, + (EnsureNoSuchFile(Path("obsolete")),), + ) + + evaluation = evaluate_policy(tmp_path, policy) + + assert evaluation.changes == (Change(Path("obsolete"), "remove file"),) + apply_policy(tmp_path, policy) + assert not link.is_symlink() + + def test_replace_regex_applies_and_is_idempotent(tmp_path: Path) -> None: (tmp_path / "example.txt").write_text("legacy\nunchanged\n") policy = Policy( @@ -562,6 +594,59 @@ def test_synchronize_workflow_rejects_job_id_collision(tmp_path: Path) -> None: apply_policy(tmp_path, policy) +def test_synchronize_workflow_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\n" + ) + 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" in result + + def test_after_apply_regenerates_existing_conditional_file( tmp_path: Path, monkeypatch ) -> None: @@ -847,6 +932,63 @@ def test_devcontainer_policy_rejects_unsupported_or_duplicate_declarations( assert module_file.read_text() == before_module +def test_ensure_bazel_dependency_ignores_commented_dependency( + tmp_path: Path, +) -> None: + dockerfile = tmp_path / ".devcontainer/Dockerfile" + dockerfile.parent.mkdir() + dockerfile.write_text("FROM ghcr.io/eclipse-score/devcontainer:v1.9.0\n") + module_file = tmp_path / "MODULE.bazel" + module_file.write_text( + '# bazel_dep(name = "score_devcontainer", version = "1.9.0")\n' + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + EnsureBazelDependency( + Path(".devcontainer/Dockerfile"), + Path("MODULE.bazel"), + "ghcr.io/eclipse-score/devcontainer", + "score_devcontainer", + ), + ), + ) + + apply_policy(tmp_path, policy) + + assert module_file.read_text().count('name = "score_devcontainer"') == 2 + + +def test_synchronize_devcontainer_version_ignores_commented_dependency( + tmp_path: Path, +) -> None: + _write_devcontainer_files(tmp_path, "1.9.0", "1.8.4") + module_file = tmp_path / "MODULE.bazel" + module_file.write_text( + '# bazel_dep(name = "score_devcontainer", version = "1.8.4")\n' + ) + policy = Policy( + "example", + "Example", + None, + None, + ( + SynchronizeDevcontainerVersion( + Path(".devcontainer/Dockerfile"), + Path("MODULE.bazel"), + "ghcr.io/eclipse-score/devcontainer", + "score_devcontainer", + ), + ), + ) + + with pytest.raises(RepoPolicySyncError, match="exactly one bazel_dep"): + apply_policy(tmp_path, policy) + + def test_after_apply_changed_path_guard_only_regenerates_lock_after_module_change( tmp_path: Path, monkeypatch ) -> None: From 456de4d0987ea3e633877c5f6e41f909c91f6449 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 14:19:34 +0200 Subject: [PATCH 04/12] fix(repo-policy-sync): address follow-up review feedback --- repo_policy_sync/engine.py | 46 +++++-- repo_policy_sync/operations/_validation.py | 42 ++++++- .../operations/ensure_bazel_dependency.py | 3 + repo_policy_sync/operations/ensure_line.py | 3 + .../operations/ensure_minimum_version.py | 3 + .../operations/ensure_no_such_file.py | 7 +- .../operations/migrate_devcontainer_json.py | 18 ++- repo_policy_sync/operations/replace_regex.py | 3 + .../synchronize_bazel_dependencies.py | 26 ++-- .../synchronize_devcontainer_version.py | 3 + .../operations/synchronize_file.py | 6 +- repo_policy_sync/tests/test_engine.py | 114 +++++++++++++++++- 12 files changed, 248 insertions(+), 26 deletions(-) diff --git a/repo_policy_sync/engine.py b/repo_policy_sync/engine.py index e27b565..ef375bc 100644 --- a/repo_policy_sync/engine.py +++ b/repo_policy_sync/engine.py @@ -26,10 +26,11 @@ parse_bazel_version, starlark_string_arguments, ) -from .errors import CommandError, redact_sensitive_text +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 def evaluate_policy( @@ -111,7 +112,9 @@ def _run_after_apply_command(root: Path, command: tuple[str, ...]) -> None: def _should_run_after_apply( root: Path, command, changed_paths: set[Path], *, force: bool = False ) -> bool: - return (root / command.when_file_exists).is_file() and ( + 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 @@ -132,6 +135,7 @@ def _matches_bazel_condition(root: Path, policy: Policy) -> bool: 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") @@ -144,6 +148,20 @@ def _matches_bazel_condition(root: Path, policy: Policy) -> bool: dependencies[name_matches[0].value] = ( parse_bazel_version(version_matches[0].value) if version_matches 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: + 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): @@ -176,7 +194,11 @@ def _matches_bazel_condition(root: Path, policy: Policy) -> bool: def _matches_file_exists_condition(root: Path, policy: Policy) -> bool: condition = policy.file_exists_condition - return condition is None or (root / condition.path).is_file() + 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: @@ -205,11 +227,17 @@ def _condition_paths(root: Path, path: Path) -> tuple[Path, ...]: # Literal paths are common, so avoid glob expansion and keep their behavior simple. if not any(character in str(path) for character in "*?["): - return (root / path,) if (root / path).is_file() else () + 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. - return tuple( - candidate - for candidate in sorted(root.glob(str(path))) + 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 candidate.is_file() and ".git" not in candidate.relative_to(root).parts - ) + 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/operations/_validation.py b/repo_policy_sync/operations/_validation.py index 574d3e5..f6fe9d7 100644 --- a/repo_policy_sync/operations/_validation.py +++ b/repo_policy_sync/operations/_validation.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Any -from ..errors import PolicyError +from ..errors import PolicyError, RepoPolicySyncError def expect_keys(value: dict[str, Any], allowed: set[str], source: Path) -> None: @@ -36,6 +36,46 @@ def safe_relative_path(raw: str, source: Path) -> 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.""" + + 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 + + current = root_absolute + parts = relative.parts + for index, part in enumerate(parts): + current /= part + if current.is_symlink() and not ( + allow_final_symlink and index == len(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: + resolved_root = root_absolute.resolve(strict=False) + resolved_path = containment_path.resolve(strict=False) + resolved_path.relative_to(resolved_root) + 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(): diff --git a/repo_policy_sync/operations/ensure_bazel_dependency.py b/repo_policy_sync/operations/ensure_bazel_dependency.py index 3a29eb3..8fccb5a 100644 --- a/repo_policy_sync/operations/ensure_bazel_dependency.py +++ b/repo_policy_sync/operations/ensure_bazel_dependency.py @@ -28,6 +28,7 @@ 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") @@ -106,6 +107,7 @@ def apply( 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") @@ -131,6 +133,7 @@ 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") diff --git a/repo_policy_sync/operations/ensure_line.py b/repo_policy_sync/operations/ensure_line.py index 95d08ea..c8d1717 100644 --- a/repo_policy_sync/operations/ensure_line.py +++ b/repo_policy_sync/operations/ensure_line.py @@ -25,6 +25,7 @@ required_string, safe_relative_path, string_list, + validate_repository_path, ) @@ -66,6 +67,7 @@ def describe_changes( ) -> 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) @@ -96,6 +98,7 @@ def apply( ) -> 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): diff --git a/repo_policy_sync/operations/ensure_minimum_version.py b/repo_policy_sync/operations/ensure_minimum_version.py index ecf6211..2fe3d70 100644 --- a/repo_policy_sync/operations/ensure_minimum_version.py +++ b/repo_policy_sync/operations/ensure_minimum_version.py @@ -24,6 +24,7 @@ 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") @@ -55,6 +56,7 @@ def describe_changes( ) -> 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 () @@ -75,6 +77,7 @@ def apply( ) -> 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 diff --git a/repo_policy_sync/operations/ensure_no_such_file.py b/repo_policy_sync/operations/ensure_no_such_file.py index 695967b..6cf6e3f 100644 --- a/repo_policy_sync/operations/ensure_no_such_file.py +++ b/repo_policy_sync/operations/ensure_no_such_file.py @@ -23,6 +23,7 @@ optional_string, required_string, safe_relative_path, + validate_repository_path, ) @@ -46,7 +47,8 @@ def describe_changes( ) -> tuple[Change, ...]: assert isinstance(operation, EnsureNoSuchFile) path = root / operation.path - if path.is_dir() and not path.is_symlink(): + 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),) @@ -63,8 +65,9 @@ def apply( ) -> 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 path.is_dir() and not path.is_symlink(): + 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 index 5eeb73f..30513f3 100644 --- a/repo_policy_sync/operations/migrate_devcontainer_json.py +++ b/repo_policy_sync/operations/migrate_devcontainer_json.py @@ -29,6 +29,7 @@ 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") @@ -110,12 +111,14 @@ def describe_changes( if source is None: return () dockerfile = root / operation.dockerfile + validate_repository_path(root, dockerfile) + destination = root / operation.destination + validate_repository_path(root, destination) migration = _migration_contents(source, operation, organization) if migration is None: return () dockerfile_contents, destination_contents = migration _validate_target(dockerfile, operation) - destination = root / operation.destination _validate_destination(destination, operation) changes: list[Change] = [] if not dockerfile.exists(): @@ -174,12 +177,14 @@ def apply( if source is None: return dockerfile = root / operation.dockerfile + validate_repository_path(root, dockerfile) + destination = root / operation.destination + validate_repository_path(root, destination) migration = _migration_contents(source, operation, organization) if migration is None: return dockerfile_contents, destination_contents = migration _validate_target(dockerfile, operation) - destination = root / operation.destination _validate_destination(destination, operation) if ( dockerfile.exists() @@ -209,9 +214,12 @@ def apply( def _find_source( root: Path, operation: MigrateDevcontainerJson ) -> tuple[Path | None, Path | None]: - matches = tuple( - (path, root / path) for path in operation.sources if (root / path).exists() - ) + 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( diff --git a/repo_policy_sync/operations/replace_regex.py b/repo_policy_sync/operations/replace_regex.py index 16fd5ca..4eaeb5c 100644 --- a/repo_policy_sync/operations/replace_regex.py +++ b/repo_policy_sync/operations/replace_regex.py @@ -24,6 +24,7 @@ optional_string, required_string, safe_relative_path, + validate_repository_path, ) @@ -59,6 +60,7 @@ def describe_changes( ) -> 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 () @@ -78,6 +80,7 @@ def apply( ) -> None: assert isinstance(operation, ReplaceRegex) path = root / operation.path + validate_repository_path(root, path) _validate_target(path, operation) if not path.is_file(): return diff --git a/repo_policy_sync/operations/synchronize_bazel_dependencies.py b/repo_policy_sync/operations/synchronize_bazel_dependencies.py index b837008..cc6aab5 100644 --- a/repo_policy_sync/operations/synchronize_bazel_dependencies.py +++ b/repo_policy_sync/operations/synchronize_bazel_dependencies.py @@ -38,6 +38,7 @@ required_string, safe_relative_path, string_list, + validate_repository_path, ) @@ -130,6 +131,7 @@ def describe_changes( ) -> 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] = [] @@ -163,6 +165,7 @@ def apply( ) -> 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") @@ -317,6 +320,12 @@ def _git_override_replacements( # _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 @@ -506,10 +515,13 @@ def _build_files( root: Path, operation: SynchronizeBazelDependencies ) -> tuple[Path, ...]: # BUILD files are selected by basename because Bazel allows them in every package. - return tuple( - path - for path in sorted(root.rglob("*")) - if path.is_file() - and path.name in operation.build_file_names - and ".git" not in path.relative_to(root).parts - ) + paths: list[Path] = [] + for path in sorted(root.rglob("*")): + relative = path.relative_to(root) + # Git metadata is not part of the repository content being synchronized. + if ".git" in relative.parts: + 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 index 694860c..ca111d5 100644 --- a/repo_policy_sync/operations/synchronize_devcontainer_version.py +++ b/repo_policy_sync/operations/synchronize_devcontainer_version.py @@ -28,6 +28,7 @@ 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") @@ -124,6 +125,7 @@ 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") @@ -150,6 +152,7 @@ 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") diff --git a/repo_policy_sync/operations/synchronize_file.py b/repo_policy_sync/operations/synchronize_file.py index 1ec6d9e..6978528 100644 --- a/repo_policy_sync/operations/synchronize_file.py +++ b/repo_policy_sync/operations/synchronize_file.py @@ -27,6 +27,7 @@ optional_string, required_string, safe_relative_path, + validate_repository_path, ) @@ -95,6 +96,7 @@ def describe_changes( ) -> 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") @@ -121,6 +123,7 @@ def apply( ) -> 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: @@ -348,7 +351,8 @@ def _merge_matching_job_permissions( existing_permissions = _nested_job_section(existing_job, "permissions") if existing_permissions is None: insertion = existing_jobs[0] + existing_job_end - return existing[:insertion] + source_permissions + existing[insertion:] + 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 diff --git a/repo_policy_sync/tests/test_engine.py b/repo_policy_sync/tests/test_engine.py index c078f24..36e4c53 100644 --- a/repo_policy_sync/tests/test_engine.py +++ b/repo_policy_sync/tests/test_engine.py @@ -22,6 +22,7 @@ from repo_policy_sync.models import ( AfterApplyCommand, BazelDependencyUpdate, + BazelDependencyCondition, BazelCondition, Change, EnsureBazelDependency, @@ -78,6 +79,35 @@ def test_policy_does_not_apply_without_direct_dependency(tmp_path: Path) -> None assert evaluation.changes == () +@pytest.mark.parametrize( + "dependency", + [ + 'bazel_dep(name = "score_docs_as_code", version = "1.0")', + 'bazel_dep(name = "score_docs_as_code", version = "1.0.0-rc1")', + 'bazel_dep(name = "score_docs_as_code")', + ], +) +def test_bazel_conditions_reject_uncomparable_configured_versions( + tmp_path: Path, dependency: str +) -> None: + (tmp_path / "MODULE.bazel").write_text(dependency + "\n") + policy = Policy( + "example", + "Example", + None, + BazelCondition( + (), + any_direct_module_conditions=( + BazelDependencyCondition("score_docs_as_code", "<", (2, 0, 0)), + ), + ), + (), + ) + + with pytest.raises(RepoPolicySyncError, match="score_docs_as_code"): + evaluate_policy(tmp_path, policy) + + def test_bazel_conditions_ignore_commented_dependencies(tmp_path: Path) -> None: (tmp_path / "MODULE.bazel").write_text( '# bazel_dep(name = "score_docs_as_code", version = "1.0")\n' @@ -170,6 +200,25 @@ def test_ensure_no_such_file_removes_a_dangling_symlink(tmp_path: Path) -> None: assert not link.is_symlink() +def test_path_operations_reject_symlinks_to_outside_checkout(tmp_path: Path) -> None: + external = tmp_path.parent / f"{tmp_path.name}-external.txt" + external.write_text("legacy\n") + (tmp_path / "managed.txt").symlink_to(external) + policy = Policy( + "example", + "Example", + None, + None, + (ReplaceRegex(Path("managed.txt"), "legacy", "current"),), + ) + + with pytest.raises(RepoPolicySyncError, match="symbolic link"): + apply_policy(tmp_path, policy) + + assert external.read_text() == "legacy\n" + external.unlink() + + def test_replace_regex_applies_and_is_idempotent(tmp_path: Path) -> None: (tmp_path / "example.txt").write_text("legacy\nunchanged\n") policy = Policy( @@ -368,6 +417,68 @@ def test_synchronize_bazel_dependencies_adds_and_updates_git_override( ) +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_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: @@ -606,7 +717,7 @@ def test_synchronize_workflow_preserves_publish_permissions_on_matching_job( " publish:\n" " uses: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml@v0.0.2\n" " with:\n" - " deployment_type: custom\n" + " deployment_type: custom" ) policy = Policy( "example", @@ -644,6 +755,7 @@ def test_synchronize_workflow_preserves_publish_permissions_on_matching_job( 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 From 40c2eb3c12708af10c690b2d19a42969e241ac34 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 15:27:36 +0200 Subject: [PATCH 05/12] fix(repo-policy-sync): address low-quality review feedback --- .../docs/explanation/execution-model.md | 7 ++-- repo_policy_sync/docs/how-to/run-a-policy.md | 5 +-- repo_policy_sync/github.py | 22 ++++++++----- repo_policy_sync/runner.py | 31 ++++++++++++++++-- repo_policy_sync/tests/test_github.py | 15 +++++++-- repo_policy_sync/tests/test_runner.py | 32 +++++++++++++++++++ 6 files changed, 94 insertions(+), 18 deletions(-) diff --git a/repo_policy_sync/docs/explanation/execution-model.md b/repo_policy_sync/docs/explanation/execution-model.md index 0d5738a..c14215b 100644 --- a/repo_policy_sync/docs/explanation/execution-model.md +++ b/repo_policy_sync/docs/explanation/execution-model.md @@ -30,9 +30,10 @@ owns one deterministic branch and one pull request per repository. 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 `pre-commit run --all-files` - 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, + 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 diff --git a/repo_policy_sync/docs/how-to/run-a-policy.md b/repo_policy_sync/docs/how-to/run-a-policy.md index 3e920c7..82772dc 100644 --- a/repo_policy_sync/docs/how-to/run-a-policy.md +++ b/repo_policy_sync/docs/how-to/run-a-policy.md @@ -105,8 +105,9 @@ read access required by the organization's token policy. Keep apply workflows manual or otherwise separately protected; the pull-request validation workflow must not pass `--apply`. -Apply mode runs the target repository's `pre-commit run --all-files` command -before publishing changes. Treat apply mode as trusted-repository execution: +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. diff --git a/repo_policy_sync/github.py b/repo_policy_sync/github.py index 776359b..a3086e0 100644 --- a/repo_policy_sync/github.py +++ b/repo_policy_sync/github.py @@ -430,7 +430,9 @@ def commit_and_push( pre_commit_failure=pre_commit_failure, ) - def run_pre_commit(self, *, checkout: Path) -> bool: + 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 @@ -440,6 +442,8 @@ def run_pre_commit(self, *, checkout: Path) -> bool: 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() @@ -455,11 +459,10 @@ def run_pre_commit(self, *, checkout: Path) -> bool: "GIT_TERMINAL_PROMPT": "0", } ) - self._run( - ["pre-commit", "run", "--all-files"], - cwd=checkout, - env=environment, - ) + 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( @@ -467,12 +470,15 @@ def _run_pre_commit( ) -> 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) + 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) + ran = self.run_pre_commit(checkout=checkout, paths=existing_paths) except CommandError as exc: if not allow_dirty_pr: raise diff --git a/repo_policy_sync/runner.py b/repo_policy_sync/runner.py index eecb884..b2ac3de 100644 --- a/repo_policy_sync/runner.py +++ b/repo_policy_sync/runner.py @@ -280,7 +280,10 @@ def run_policies( "pull-request-recreated-no-changes", }: pull_requests_recreated += 1 - elif outcome.status == "pull-request-closed": + if outcome.status == "pull-request-closed" or ( + outcome.status == "not-applicable" + and outcome.policy_pr_status == "closed" + ): pull_requests_closed += 1 return RunReport( RunSummary( @@ -451,6 +454,28 @@ def _run_repository( ) 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( @@ -486,7 +511,7 @@ def _run_repository( ) ) if apply and existing_pr is not None: - _close_compliant_pull_request( + _close_owned_pull_request( client=client, repository=full_name, policy=policy, @@ -721,7 +746,7 @@ def _policy_pr_url(status: PolicyPullRequestStatus | None) -> str | None: return pull_request.url if pull_request is not None else None -def _close_compliant_pull_request( +def _close_owned_pull_request( *, client: RepositoryClient, repository: str, diff --git a/repo_policy_sync/tests/test_github.py b/repo_policy_sync/tests/test_github.py index 6a17283..839c5de 100644 --- a/repo_policy_sync/tests/test_github.py +++ b/repo_policy_sync/tests/test_github.py @@ -159,6 +159,7 @@ 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( @@ -189,7 +190,10 @@ def record( "--", ".gitignore", ] - assert commands[1] == (["pre-commit", "run", "--all-files"], tmp_path) + assert commands[1] == ( + ["pre-commit", "run", "--files", ".gitignore"], + tmp_path, + ) assert commands[2][0] == [ "git", "-C", @@ -235,6 +239,7 @@ def record( 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( @@ -271,6 +276,7 @@ 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 @@ -327,6 +333,7 @@ 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( @@ -879,6 +886,7 @@ def test_recreate_force_push_runs_pre_commit_before_commit( monkeypatch, tmp_path: Path ) -> None: (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + (tmp_path / "MODULE.bazel.lock").write_text("lock\n") commands: list[tuple[list[str], Path | None]] = [] def record( @@ -910,7 +918,10 @@ def record( "--", "MODULE.bazel.lock", ] - assert commands[1] == (["pre-commit", "run", "--all-files"], tmp_path) + assert commands[1] == ( + ["pre-commit", "run", "--files", "MODULE.bazel.lock"], + tmp_path, + ) assert commands[2][0] == [ "git", "-C", diff --git a/repo_policy_sync/tests/test_runner.py b/repo_policy_sync/tests/test_runner.py index 5e5a3ef..e3ac920 100644 --- a/repo_policy_sync/tests/test_runner.py +++ b/repo_policy_sync/tests/test_runner.py @@ -800,6 +800,38 @@ def test_apply_closes_owned_pull_request_after_default_branch_compliance( 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: From 1b1c296990cec54af8f6c5be45c6d7e8589a0b2c Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 16:01:48 +0200 Subject: [PATCH 06/12] remove AI induced complexity --- repo_policy_sync/bazel.py | 187 ------------------ repo_policy_sync/engine.py | 54 ++--- repo_policy_sync/operations/_validation.py | 42 +--- .../operations/ensure_bazel_dependency.py | 19 +- repo_policy_sync/operations/ensure_line.py | 3 - .../operations/ensure_minimum_version.py | 3 - .../operations/ensure_no_such_file.py | 3 - .../operations/migrate_devcontainer_json.py | 6 - repo_policy_sync/operations/replace_regex.py | 3 - .../synchronize_bazel_dependencies.py | 98 ++++----- .../synchronize_devcontainer_version.py | 23 ++- .../operations/synchronize_file.py | 3 - repo_policy_sync/tests/test_engine.py | 138 ------------- 13 files changed, 87 insertions(+), 495 deletions(-) diff --git a/repo_policy_sync/bazel.py b/repo_policy_sync/bazel.py index 111c718..2abfd45 100644 --- a/repo_policy_sync/bazel.py +++ b/repo_policy_sync/bazel.py @@ -16,32 +16,11 @@ from __future__ import annotations import re -from dataclasses import dataclass from .models import BazelDependencyCondition BazelVersion = tuple[int, int, int] - -@dataclass(frozen=True) -class StarlarkCall: - """A function call found outside Starlark strings and comments.""" - - start: int - body_start: int - body_end: int - - -@dataclass(frozen=True) -class StarlarkStringArgument: - """A quoted keyword argument and its value span in the source text.""" - - key: str - value: str - value_start: int - value_end: 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*" @@ -55,172 +34,6 @@ def parse_bazel_version(value: str) -> BazelVersion | None: match = _VERSION.fullmatch(value) return tuple(int(component) for component in match.groups()) if match else None - -def find_starlark_calls(text: str, function_name: str) -> tuple[StarlarkCall, ...]: - """Find calls to ``function_name`` while ignoring strings and comments.""" - - masked = _mask_starlark(text) - function = re.compile(rf"\b{re.escape(function_name)}\s*\(") - calls: list[StarlarkCall] = [] - for match in function.finditer(masked): - opening = masked.find("(", match.start(), match.end()) - depth = 0 - for index in range(opening, len(masked)): - character = masked[index] - if character == "(": - depth += 1 - elif character == ")": - depth -= 1 - if depth == 0: - calls.append(StarlarkCall(match.start(), opening + 1, index)) - break - return tuple(calls) - - -def starlark_string_arguments( - text: str, call: StarlarkCall, key: str -) -> tuple[StarlarkStringArgument, ...]: - """Find direct quoted ``key = "value"`` arguments in one call.""" - - arguments: list[StarlarkStringArgument] = [] - index = call.body_start - depth = 0 - while index < call.body_end: - character = text[index] - if character == "#": - index = _skip_comment(text, index) - continue - if character in "'\"": - index = _skip_string(text, index) - continue - if character == "(": - depth += 1 - index += 1 - continue - if character == ")": - depth -= 1 - index += 1 - continue - if depth != 0 or not (character.isalpha() or character == "_"): - index += 1 - continue - - key_start = index - index += 1 - while index < call.body_end and (text[index].isalnum() or text[index] == "_"): - index += 1 - if text[key_start:index] != key: - continue - index = _skip_whitespace_and_comments(text, index) - if index >= call.body_end or text[index] != "=": - continue - index = _skip_whitespace_and_comments(text, index + 1) - if index >= call.body_end or text[index] not in "'\"": - continue - delimiter = _string_delimiter(text, index) - value_start = index + len(delimiter) - value_end = _skip_string(text, index) - content_end = value_end - len(delimiter) - if value_end <= value_start or text[content_end:value_end] != delimiter: - continue - arguments.append( - StarlarkStringArgument( - key, - text[value_start:content_end], - value_start, - content_end, - ) - ) - index = value_end - return tuple(arguments) - - -def _mask_starlark(text: str) -> str: - masked = list(text) - index = 0 - while index < len(text): - character = text[index] - if character == "#": - index = _mask_comment(text, masked, index) - elif character in "'\"": - index = _mask_string(text, masked, index) - else: - index += 1 - return "".join(masked) - - -def _mask_comment(text: str, masked: list[str], start: int) -> int: - index = start - while index < len(text) and text[index] not in "\r\n": - masked[index] = " " - index += 1 - return index - - -def _mask_string(text: str, masked: list[str], start: int) -> int: - delimiter = _string_delimiter(text, start) - for offset in range(len(delimiter)): - if start + offset < len(masked) and text[start + offset] not in "\r\n": - masked[start + offset] = " " - index = start + 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)): - if index + offset < len(masked) and text[index + offset] not in "\r\n": - masked[index + offset] = " " - return index + len(delimiter) - index += 1 - return index - - -def _skip_comment(text: str, start: int) -> int: - index = start - while index < len(text) and text[index] not in "\r\n": - index += 1 - return index - - -def _skip_string(text: str, start: int) -> int: - delimiter = _string_delimiter(text, start) - index = start + len(delimiter) - escaped = False - while index < len(text): - character = text[index] - if escaped: - escaped = False - elif character == "\\": - escaped = True - elif text.startswith(delimiter, index): - return index + len(delimiter) - index += 1 - return index - - -def _string_delimiter(text: str, start: int) -> str: - quote = text[start] - return quote * 3 if text.startswith(quote * 3, start) else quote - - -def _skip_whitespace_and_comments(text: str, start: int) -> int: - index = start - while index < len(text): - if text[index].isspace(): - index += 1 - elif text[index] == "#": - index = _skip_comment(text, index) - else: - break - return index - - def parse_bazel_dependency_condition(value: str) -> BazelDependencyCondition | None: """Parse ``module OP major.minor.patch`` condition syntax.""" diff --git a/repo_policy_sync/engine.py b/repo_policy_sync/engine.py index ef375bc..3980afa 100644 --- a/repo_policy_sync/engine.py +++ b/repo_policy_sync/engine.py @@ -20,17 +20,15 @@ import subprocess from pathlib import Path -from .bazel import ( - find_starlark_calls, - matches_bazel_dependency_condition, - parse_bazel_version, - starlark_string_arguments, -) -from .errors import CommandError, RepoPolicySyncError, redact_sensitive_text +from .bazel import matches_bazel_dependency_condition, parse_bazel_version +from .errors import CommandError, 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 + +_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) +_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") +_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*[\"']([^\"']+)[\"']") def evaluate_policy( @@ -112,9 +110,7 @@ def _run_after_apply_command(root: Path, command: tuple[str, ...]) -> None: 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 ( + return (root / command.when_file_exists).is_file() and ( force or command.when_path_changed is None or command.when_path_changed in changed_paths @@ -135,32 +131,17 @@ def _matches_bazel_condition(root: Path, policy: Policy) -> bool: 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 call in find_starlark_calls(text, "bazel_dep"): - name_matches = starlark_string_arguments(text, call, "name") - if not name_matches: + for call in _BAZEL_DEP_CALL.finditer(text): + name_match = _NAME_ARGUMENT.search(call.group(1)) + if name_match is None: continue - version_matches = starlark_string_arguments(text, call, "version") - dependencies[name_matches[0].value] = ( - parse_bazel_version(version_matches[0].value) if version_matches 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: - 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}" + version_match = _VERSION_ARGUMENT.search(call.group(1)) + dependencies[name_match.group(1)] = ( + parse_bazel_version(version_match.group(1)) if version_match else None ) # A policy can require a complete set and also accept one of several names. dependency_names = set(dependencies) @@ -196,9 +177,7 @@ 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() + return (root / condition.path).is_file() def _matches_file_contains_condition(root: Path, policy: Policy) -> bool: @@ -227,9 +206,7 @@ def _condition_paths(root: Path, path: Path) -> tuple[Path, ...]: # 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 () + return (root / path,) if (root / path).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))): @@ -237,7 +214,6 @@ def _condition_paths(root: Path, path: Path) -> tuple[Path, ...]: # 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/operations/_validation.py b/repo_policy_sync/operations/_validation.py index f6fe9d7..574d3e5 100644 --- a/repo_policy_sync/operations/_validation.py +++ b/repo_policy_sync/operations/_validation.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Any -from ..errors import PolicyError, RepoPolicySyncError +from ..errors import PolicyError def expect_keys(value: dict[str, Any], allowed: set[str], source: Path) -> None: @@ -36,46 +36,6 @@ def safe_relative_path(raw: str, source: Path) -> 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.""" - - 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 - - current = root_absolute - parts = relative.parts - for index, part in enumerate(parts): - current /= part - if current.is_symlink() and not ( - allow_final_symlink and index == len(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: - resolved_root = root_absolute.resolve(strict=False) - resolved_path = containment_path.resolve(strict=False) - resolved_path.relative_to(resolved_root) - 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(): diff --git a/repo_policy_sync/operations/ensure_bazel_dependency.py b/repo_policy_sync/operations/ensure_bazel_dependency.py index 8fccb5a..50ef4ba 100644 --- a/repo_policy_sync/operations/ensure_bazel_dependency.py +++ b/repo_policy_sync/operations/ensure_bazel_dependency.py @@ -20,7 +20,6 @@ from pathlib import Path from typing import Any -from ..bazel import find_starlark_calls, starlark_string_arguments from ..errors import RepoPolicySyncError from ..models import Change, EnsureBazelDependency, EnsureOperation from ._validation import ( @@ -28,10 +27,12 @@ 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") +_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) +_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") +_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") @dataclass(frozen=True) @@ -107,7 +108,6 @@ def apply( 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") @@ -133,29 +133,28 @@ 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 call in find_starlark_calls(text, "bazel_dep"): - name_matches = starlark_string_arguments(text, call, "name") + for match in _BAZEL_DEP_CALL.finditer(text): + name_matches = list(_NAME_ARGUMENT.finditer(match.group(1))) if any( - name_match.value == operation.module_name for name_match in name_matches + 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(call) + calls.append(match) 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 = starlark_string_arguments(text, calls[0], "version") + version_matches = list(_VERSION_ARGUMENT.finditer(calls[0].group(1))) if not version_matches: raise RepoPolicySyncError( f'{operation.module_file} bazel_dep for {operation.module_name!r} must declare version = "X.Y.Z"' @@ -164,7 +163,7 @@ def _module_dependency( raise RepoPolicySyncError( f"{operation.module_file} bazel_dep for {operation.module_name!r} must declare version exactly once" ) - version = version_matches[0].value + 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}" diff --git a/repo_policy_sync/operations/ensure_line.py b/repo_policy_sync/operations/ensure_line.py index c8d1717..95d08ea 100644 --- a/repo_policy_sync/operations/ensure_line.py +++ b/repo_policy_sync/operations/ensure_line.py @@ -25,7 +25,6 @@ required_string, safe_relative_path, string_list, - validate_repository_path, ) @@ -67,7 +66,6 @@ def describe_changes( ) -> 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) @@ -98,7 +96,6 @@ def apply( ) -> 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): diff --git a/repo_policy_sync/operations/ensure_minimum_version.py b/repo_policy_sync/operations/ensure_minimum_version.py index 2fe3d70..ecf6211 100644 --- a/repo_policy_sync/operations/ensure_minimum_version.py +++ b/repo_policy_sync/operations/ensure_minimum_version.py @@ -24,7 +24,6 @@ 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") @@ -56,7 +55,6 @@ def describe_changes( ) -> 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 () @@ -77,7 +75,6 @@ def apply( ) -> 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 diff --git a/repo_policy_sync/operations/ensure_no_such_file.py b/repo_policy_sync/operations/ensure_no_such_file.py index 6cf6e3f..3dbb3cd 100644 --- a/repo_policy_sync/operations/ensure_no_such_file.py +++ b/repo_policy_sync/operations/ensure_no_such_file.py @@ -23,7 +23,6 @@ optional_string, required_string, safe_relative_path, - validate_repository_path, ) @@ -47,7 +46,6 @@ def describe_changes( ) -> 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 ( @@ -65,7 +63,6 @@ def apply( ) -> 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(): diff --git a/repo_policy_sync/operations/migrate_devcontainer_json.py b/repo_policy_sync/operations/migrate_devcontainer_json.py index 30513f3..c862b15 100644 --- a/repo_policy_sync/operations/migrate_devcontainer_json.py +++ b/repo_policy_sync/operations/migrate_devcontainer_json.py @@ -29,7 +29,6 @@ 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") @@ -111,9 +110,7 @@ def describe_changes( if source is None: return () dockerfile = root / operation.dockerfile - validate_repository_path(root, dockerfile) destination = root / operation.destination - validate_repository_path(root, destination) migration = _migration_contents(source, operation, organization) if migration is None: return () @@ -177,9 +174,7 @@ def apply( if source is None: return dockerfile = root / operation.dockerfile - validate_repository_path(root, dockerfile) destination = root / operation.destination - validate_repository_path(root, destination) migration = _migration_contents(source, operation, organization) if migration is None: return @@ -217,7 +212,6 @@ def _find_source( 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: diff --git a/repo_policy_sync/operations/replace_regex.py b/repo_policy_sync/operations/replace_regex.py index 4eaeb5c..16fd5ca 100644 --- a/repo_policy_sync/operations/replace_regex.py +++ b/repo_policy_sync/operations/replace_regex.py @@ -24,7 +24,6 @@ optional_string, required_string, safe_relative_path, - validate_repository_path, ) @@ -60,7 +59,6 @@ def describe_changes( ) -> 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 () @@ -80,7 +78,6 @@ def apply( ) -> None: assert isinstance(operation, ReplaceRegex) path = root / operation.path - validate_repository_path(root, path) _validate_target(path, operation) if not path.is_file(): return diff --git a/repo_policy_sync/operations/synchronize_bazel_dependencies.py b/repo_policy_sync/operations/synchronize_bazel_dependencies.py index cc6aab5..05df49d 100644 --- a/repo_policy_sync/operations/synchronize_bazel_dependencies.py +++ b/repo_policy_sync/operations/synchronize_bazel_dependencies.py @@ -20,11 +20,7 @@ from pathlib import Path from typing import Any -from ..bazel import ( - find_starlark_calls, - parse_bazel_version, - starlark_string_arguments, -) +from ..bazel import parse_bazel_version from ..errors import PolicyError, RepoPolicySyncError from ..models import ( BazelDependencyUpdate, @@ -38,9 +34,17 @@ required_string, safe_relative_path, string_list, - validate_repository_path, ) +# Keep the complete call text so argument values can be located exactly. +_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) +_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*([\"'])([^\"']+)\1") +_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") +_GIT_OVERRIDE_CALL = re.compile(r"git_override\s*\((.*?)\)", re.DOTALL) +_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: @@ -131,7 +135,6 @@ def describe_changes( ) -> 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] = [] @@ -165,7 +168,6 @@ def apply( ) -> 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") @@ -394,53 +396,56 @@ def _git_override_locations( for name in _dependency_names(dependency) } locations: dict[str, _GitOverrideLocation] = {} - for call in find_starlark_calls(text, "git_override"): - module_name_matches = starlark_string_arguments(text, call, "module_name") + for call in _GIT_OVERRIDE_CALL.finditer(text): + module_name_matches = list(_MODULE_NAME_ARGUMENT.finditer(call.group(1))) matching_names = [ - match for match in module_name_matches if match.value in configured_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].value!r}" + f"for {matching_names[0].group(2)!r}" ) module_name_match = matching_names[0] - module_name = module_name_match.value + 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 = starlark_string_arguments(text, call, "commit") + commit_matches = list(_COMMIT_ARGUMENT.finditer(call.group(1))) 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 = starlark_string_arguments(text, call, "remote") + remote_matches = list(_REMOTE_ARGUMENT.finditer(call.group(1))) 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 + call_content_start = call.start(1) locations[module_name] = _GitOverrideLocation( module_name=module_name, - commit=commit_match.value, - module_name_start=module_name_match.value_start, - module_name_end=module_name_match.value_end, - commit_start=commit_match.value_start, - commit_end=commit_match.value_end, - remote=remote_match.value if remote_match else None, - remote_start=remote_match.value_start if remote_match else None, - remote_end=remote_match.value_end if remote_match else None, - remote_insertion=call.body_end, - remote_insertion_prefix=( - "" if text[call.body_start : call.body_end].endswith("\n") else "\n" - ), + commit=commit_match.group(2), + module_name_start=call_content_start + module_name_match.start(2), + module_name_end=call_content_start + module_name_match.end(2), + commit_start=call_content_start + commit_match.start(2), + commit_end=call_content_start + commit_match.end(2), + remote=remote_match.group(2) if remote_match else None, + remote_start=(call_content_start + remote_match.start(2)) + if remote_match + else None, + remote_end=(call_content_start + remote_match.end(2)) + if remote_match + else None, + remote_insertion=call.end(1), + remote_insertion_prefix="" if call.group(1).endswith("\n") else "\n", ) return locations @@ -454,10 +459,10 @@ def _module_locations( for dependency in operation.dependencies for name in _dependency_names(dependency) } - for call in find_starlark_calls(text, "bazel_dep"): - name_matches = starlark_string_arguments(text, call, "name") + for call in _BAZEL_DEP_CALL.finditer(text): + name_matches = list(_NAME_ARGUMENT.finditer(call.group(1))) matching_names = [ - match for match in name_matches if match.value in configured_names + match for match in name_matches if match.group(2) in configured_names ] if not matching_names: continue @@ -466,33 +471,34 @@ def _module_locations( if len(name_matches) != 1: raise RepoPolicySyncError( f"{operation.module_file} bazel_dep must declare name exactly once for " - f"{matching_names[0].value!r}" + f"{matching_names[0].group(2)!r}" ) name_match = matching_names[0] - name = name_match.value + 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 = starlark_string_arguments(text, call, "version") + version_matches = list(_VERSION_ARGUMENT.finditer(call.group(1))) 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.value + 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}" ) + call_content_start = call.start(1) locations[name] = _DependencyLocation( name=name, version=version, - name_start=name_match.value_start, - name_end=name_match.value_end, - version_start=version_match.value_start, - version_end=version_match.value_end, + name_start=call_content_start + name_match.start(2), + name_end=call_content_start + name_match.end(2), + version_start=call_content_start + version_match.start(2), + version_end=call_content_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. @@ -515,13 +521,11 @@ 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) + return tuple( + path + for path in sorted(root.rglob("*")) # Git metadata is not part of the repository content being synchronized. - if ".git" in relative.parts: - continue - validate_repository_path(root, path) - if path.is_file() and path.name in operation.build_file_names: - paths.append(path) - return tuple(paths) + if path.is_file() + and path.name in operation.build_file_names + and ".git" not in path.relative_to(root).parts + ) diff --git a/repo_policy_sync/operations/synchronize_devcontainer_version.py b/repo_policy_sync/operations/synchronize_devcontainer_version.py index ca111d5..837ba61 100644 --- a/repo_policy_sync/operations/synchronize_devcontainer_version.py +++ b/repo_policy_sync/operations/synchronize_devcontainer_version.py @@ -20,7 +20,6 @@ from pathlib import Path from typing import Any -from ..bazel import find_starlark_calls, starlark_string_arguments from ..errors import RepoPolicySyncError from ..models import Change, EnsureOperation, SynchronizeDevcontainerVersion from ._validation import ( @@ -28,10 +27,12 @@ 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") +_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) +_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") +_VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") @dataclass(frozen=True) @@ -125,7 +126,6 @@ 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") @@ -152,27 +152,26 @@ 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 call in find_starlark_calls(text, "bazel_dep"): - name_matches = starlark_string_arguments(text, call, "name") + for match in _BAZEL_DEP_CALL.finditer(text): + name_matches = list(_NAME_ARGUMENT.finditer(match.group(1))) if any( - name_match.value == operation.module_name for name_match in name_matches + 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(call) + calls.append(match) if len(calls) != 1: raise RepoPolicySyncError( f"{operation.module_file} must contain exactly one bazel_dep for {operation.module_name!r}" ) - version_matches = starlark_string_arguments(text, calls[0], "version") + version_matches = list(_VERSION_ARGUMENT.finditer(calls[0].group(1))) if not version_matches: raise RepoPolicySyncError( f'{operation.module_file} bazel_dep for {operation.module_name!r} must declare version = "X.Y.Z"' @@ -182,14 +181,14 @@ def _module_location( 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.value + 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 = version_match.value_start - end = version_match.value_end + start = calls[0].start(1) + version_match.start(2) + end = calls[0].start(1) + version_match.end(2) return _VersionLocation(operation.module_file, text, start, end, version) diff --git a/repo_policy_sync/operations/synchronize_file.py b/repo_policy_sync/operations/synchronize_file.py index 6978528..81e5ecd 100644 --- a/repo_policy_sync/operations/synchronize_file.py +++ b/repo_policy_sync/operations/synchronize_file.py @@ -27,7 +27,6 @@ optional_string, required_string, safe_relative_path, - validate_repository_path, ) @@ -96,7 +95,6 @@ def describe_changes( ) -> 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") @@ -123,7 +121,6 @@ def apply( ) -> 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: diff --git a/repo_policy_sync/tests/test_engine.py b/repo_policy_sync/tests/test_engine.py index 36e4c53..bdc68fd 100644 --- a/repo_policy_sync/tests/test_engine.py +++ b/repo_policy_sync/tests/test_engine.py @@ -22,10 +22,8 @@ from repo_policy_sync.models import ( AfterApplyCommand, BazelDependencyUpdate, - BazelDependencyCondition, BazelCondition, Change, - EnsureBazelDependency, EnsureLine, EnsureMinimumVersion, EnsureNoSuchFile, @@ -79,48 +77,6 @@ def test_policy_does_not_apply_without_direct_dependency(tmp_path: Path) -> None assert evaluation.changes == () -@pytest.mark.parametrize( - "dependency", - [ - 'bazel_dep(name = "score_docs_as_code", version = "1.0")', - 'bazel_dep(name = "score_docs_as_code", version = "1.0.0-rc1")', - 'bazel_dep(name = "score_docs_as_code")', - ], -) -def test_bazel_conditions_reject_uncomparable_configured_versions( - tmp_path: Path, dependency: str -) -> None: - (tmp_path / "MODULE.bazel").write_text(dependency + "\n") - policy = Policy( - "example", - "Example", - None, - BazelCondition( - (), - any_direct_module_conditions=( - BazelDependencyCondition("score_docs_as_code", "<", (2, 0, 0)), - ), - ), - (), - ) - - with pytest.raises(RepoPolicySyncError, match="score_docs_as_code"): - evaluate_policy(tmp_path, policy) - - -def test_bazel_conditions_ignore_commented_dependencies(tmp_path: Path) -> None: - (tmp_path / "MODULE.bazel").write_text( - '# bazel_dep(name = "score_docs_as_code", version = "1.0")\n' - 'module(name = "example")\n' - "note = \"bazel_dep(name = 'score_docs_as_code', version = '1.0')\"\n" - ) - - evaluation = evaluate_policy(tmp_path, _policy()) - - assert not evaluation.applies - assert evaluation.changes == () - - 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") @@ -182,43 +138,6 @@ def test_ensure_no_such_file_refuses_a_directory_during_evaluation( evaluate_policy(tmp_path, _policy()) -def test_ensure_no_such_file_removes_a_dangling_symlink(tmp_path: Path) -> None: - link = tmp_path / "obsolete" - link.symlink_to("missing") - policy = Policy( - "example", - "Example", - None, - None, - (EnsureNoSuchFile(Path("obsolete")),), - ) - - evaluation = evaluate_policy(tmp_path, policy) - - assert evaluation.changes == (Change(Path("obsolete"), "remove file"),) - apply_policy(tmp_path, policy) - assert not link.is_symlink() - - -def test_path_operations_reject_symlinks_to_outside_checkout(tmp_path: Path) -> None: - external = tmp_path.parent / f"{tmp_path.name}-external.txt" - external.write_text("legacy\n") - (tmp_path / "managed.txt").symlink_to(external) - policy = Policy( - "example", - "Example", - None, - None, - (ReplaceRegex(Path("managed.txt"), "legacy", "current"),), - ) - - with pytest.raises(RepoPolicySyncError, match="symbolic link"): - apply_policy(tmp_path, policy) - - assert external.read_text() == "legacy\n" - external.unlink() - - def test_replace_regex_applies_and_is_idempotent(tmp_path: Path) -> None: (tmp_path / "example.txt").write_text("legacy\nunchanged\n") policy = Policy( @@ -1044,63 +963,6 @@ def test_devcontainer_policy_rejects_unsupported_or_duplicate_declarations( assert module_file.read_text() == before_module -def test_ensure_bazel_dependency_ignores_commented_dependency( - tmp_path: Path, -) -> None: - dockerfile = tmp_path / ".devcontainer/Dockerfile" - dockerfile.parent.mkdir() - dockerfile.write_text("FROM ghcr.io/eclipse-score/devcontainer:v1.9.0\n") - module_file = tmp_path / "MODULE.bazel" - module_file.write_text( - '# bazel_dep(name = "score_devcontainer", version = "1.9.0")\n' - ) - policy = Policy( - "example", - "Example", - None, - None, - ( - EnsureBazelDependency( - Path(".devcontainer/Dockerfile"), - Path("MODULE.bazel"), - "ghcr.io/eclipse-score/devcontainer", - "score_devcontainer", - ), - ), - ) - - apply_policy(tmp_path, policy) - - assert module_file.read_text().count('name = "score_devcontainer"') == 2 - - -def test_synchronize_devcontainer_version_ignores_commented_dependency( - tmp_path: Path, -) -> None: - _write_devcontainer_files(tmp_path, "1.9.0", "1.8.4") - module_file = tmp_path / "MODULE.bazel" - module_file.write_text( - '# bazel_dep(name = "score_devcontainer", version = "1.8.4")\n' - ) - policy = Policy( - "example", - "Example", - None, - None, - ( - SynchronizeDevcontainerVersion( - Path(".devcontainer/Dockerfile"), - Path("MODULE.bazel"), - "ghcr.io/eclipse-score/devcontainer", - "score_devcontainer", - ), - ), - ) - - with pytest.raises(RepoPolicySyncError, match="exactly one bazel_dep"): - apply_policy(tmp_path, policy) - - def test_after_apply_changed_path_guard_only_regenerates_lock_after_module_change( tmp_path: Path, monkeypatch ) -> None: From 34131216f10509667a29dcdd410d75f179bc284f Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 16:03:54 +0200 Subject: [PATCH 07/12] format --- repo_policy_sync/bazel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/repo_policy_sync/bazel.py b/repo_policy_sync/bazel.py index 2abfd45..2072441 100644 --- a/repo_policy_sync/bazel.py +++ b/repo_policy_sync/bazel.py @@ -34,6 +34,7 @@ def parse_bazel_version(value: str) -> BazelVersion | None: match = _VERSION.fullmatch(value) return tuple(int(component) for component in match.groups()) if match else None + def parse_bazel_dependency_condition(value: str) -> BazelDependencyCondition | None: """Parse ``module OP major.minor.patch`` condition syntax.""" From 57586dc2f050fcb114c4e1b895dacf9a0ca3e91e Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 17:21:24 +0200 Subject: [PATCH 08/12] undo simplification --- pyproject.toml | 2 +- repo_policy_sync/bazel.py | 62 ++++ repo_policy_sync/cli.py | 24 +- repo_policy_sync/engine.py | 95 +++++- repo_policy_sync/github.py | 18 +- repo_policy_sync/operations/README.md | 4 + repo_policy_sync/operations/_validation.py | 49 ++- .../operations/ensure_bazel_dependency.py | 16 +- repo_policy_sync/operations/ensure_line.py | 3 + .../operations/ensure_minimum_version.py | 3 + .../operations/ensure_no_such_file.py | 3 + .../operations/migrate_devcontainer_json.py | 39 ++- repo_policy_sync/operations/replace_regex.py | 3 + .../synchronize_bazel_dependencies.py | 82 ++--- .../synchronize_devcontainer_version.py | 20 +- .../operations/synchronize_file.py | 65 +++- repo_policy_sync/tests/test_cli.py | 40 +++ repo_policy_sync/tests/test_engine.py | 279 +++++++++++++++++- repo_policy_sync/tests/test_github.py | 9 +- uv.lock | 2 +- 20 files changed, 722 insertions(+), 96 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cf3ed59..0e2ce14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tools" -version = "0.0.0" +version = "0.1.0" description = "Gathering of different small tools used throughout S-CORE" readme = "README.md" requires-python = ">=3.12" diff --git a/repo_policy_sync/bazel.py b/repo_policy_sync/bazel.py index 2072441..09a4853 100644 --- a/repo_policy_sync/bazel.py +++ b/repo_policy_sync/bazel.py @@ -35,6 +35,68 @@ def parse_bazel_version(value: str) -> BazelVersion | None: 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.""" diff --git a/repo_policy_sync/cli.py b/repo_policy_sync/cli.py index 840aa99..320d5a6 100644 --- a/repo_policy_sync/cli.py +++ b/repo_policy_sync/cli.py @@ -310,13 +310,23 @@ def _resolve_recreate_policy_paths( if policy_directories is not None else ((DEFAULT_POLICY_DIRECTORY,) if DEFAULT_POLICY_DIRECTORY.is_dir() else ()) ) - if configured_directories: - try: - return resolve_policy_names(policy_names, configured_directories) - except PolicyError as exc: - if not str(exc).startswith("unknown policy name(s):"): - raise - return resolve_policy_names(policy_names, BUNDLED_POLICY_DIRECTORY) + # 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: diff --git a/repo_policy_sync/engine.py b/repo_policy_sync/engine.py index 3980afa..af45481 100644 --- a/repo_policy_sync/engine.py +++ b/repo_policy_sync/engine.py @@ -15,20 +15,38 @@ 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 -from .errors import CommandError, redact_sensitive_text +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 -_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) _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( @@ -87,14 +105,33 @@ def apply_policy( 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: - subprocess.run( - command, - cwd=root, - check=True, - capture_output=True, - text=True, - ) + 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: @@ -110,7 +147,9 @@ def _run_after_apply_command(root: Path, command: tuple[str, ...]) -> None: def _should_run_after_apply( root: Path, command, changed_paths: set[Path], *, force: bool = False ) -> bool: - return (root / command.when_file_exists).is_file() and ( + 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 @@ -131,18 +170,37 @@ def _matches_bazel_condition(root: Path, policy: Policy) -> bool: 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 call in _BAZEL_DEP_CALL.finditer(text): - name_match = _NAME_ARGUMENT.search(call.group(1)) + 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(call.group(1)) + 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): @@ -177,7 +235,9 @@ def _matches_file_exists_condition(root: Path, policy: Policy) -> bool: condition = policy.file_exists_condition if condition is None: return True - return (root / condition.path).is_file() + path = root / condition.path + validate_repository_path(root, path) + return path.is_file() def _matches_file_contains_condition(root: Path, policy: Policy) -> bool: @@ -206,7 +266,9 @@ def _condition_paths(root: Path, path: Path) -> tuple[Path, ...]: # Literal paths are common, so avoid glob expansion and keep their behavior simple. if not any(character in str(path) for character in "*?["): - return (root / path,) if (root / path).is_file() else () + 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))): @@ -214,6 +276,7 @@ def _condition_paths(root: Path, path: Path) -> tuple[Path, ...]: # 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/github.py b/repo_policy_sync/github.py index a3086e0..3717d11 100644 --- a/repo_policy_sync/github.py +++ b/repo_policy_sync/github.py @@ -336,6 +336,10 @@ def _find_policy_pull_requests( 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}" @@ -667,7 +671,19 @@ def update_pull_request( def close_pull_request(self, *, repository: str, pull_request: PullRequest) -> None: """Close a policy-owned pull request after ownership is verified.""" - self._run(["gh", "pr", "close", pull_request.url, "--repo", repository]) + # 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 diff --git a/repo_policy_sync/operations/README.md b/repo_policy_sync/operations/README.md index 08508a3..498155c 100644 --- a/repo_policy_sync/operations/README.md +++ b/repo_policy_sync/operations/README.md @@ -39,6 +39,10 @@ authoritative source for the complete schema, validation rules, and examples. 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 diff --git a/repo_policy_sync/operations/_validation.py b/repo_policy_sync/operations/_validation.py index 574d3e5..802195d 100644 --- a/repo_policy_sync/operations/_validation.py +++ b/repo_policy_sync/operations/_validation.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Any -from ..errors import PolicyError +from ..errors import PolicyError, RepoPolicySyncError def expect_keys(value: dict[str, Any], allowed: set[str], source: Path) -> None: @@ -36,6 +36,53 @@ def safe_relative_path(raw: str, source: Path) -> 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(): diff --git a/repo_policy_sync/operations/ensure_bazel_dependency.py b/repo_policy_sync/operations/ensure_bazel_dependency.py index 50ef4ba..a19e004 100644 --- a/repo_policy_sync/operations/ensure_bazel_dependency.py +++ b/repo_policy_sync/operations/ensure_bazel_dependency.py @@ -20,6 +20,7 @@ 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 ( @@ -27,10 +28,10 @@ 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") -_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) _NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") _VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") @@ -108,6 +109,7 @@ def apply( 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") @@ -133,12 +135,16 @@ 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 match in _BAZEL_DEP_CALL.finditer(text): - name_matches = list(_NAME_ARGUMENT.finditer(match.group(1))) + 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 ): @@ -147,14 +153,14 @@ def _module_dependency( f"{operation.module_file} bazel_dep for {operation.module_name!r} " "must declare name exactly once" ) - calls.append(match) + 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(calls[0].group(1))) + 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"' diff --git a/repo_policy_sync/operations/ensure_line.py b/repo_policy_sync/operations/ensure_line.py index 95d08ea..c8d1717 100644 --- a/repo_policy_sync/operations/ensure_line.py +++ b/repo_policy_sync/operations/ensure_line.py @@ -25,6 +25,7 @@ required_string, safe_relative_path, string_list, + validate_repository_path, ) @@ -66,6 +67,7 @@ def describe_changes( ) -> 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) @@ -96,6 +98,7 @@ def apply( ) -> 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): diff --git a/repo_policy_sync/operations/ensure_minimum_version.py b/repo_policy_sync/operations/ensure_minimum_version.py index ecf6211..2fe3d70 100644 --- a/repo_policy_sync/operations/ensure_minimum_version.py +++ b/repo_policy_sync/operations/ensure_minimum_version.py @@ -24,6 +24,7 @@ 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") @@ -55,6 +56,7 @@ def describe_changes( ) -> 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 () @@ -75,6 +77,7 @@ def apply( ) -> 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 diff --git a/repo_policy_sync/operations/ensure_no_such_file.py b/repo_policy_sync/operations/ensure_no_such_file.py index 3dbb3cd..6cf6e3f 100644 --- a/repo_policy_sync/operations/ensure_no_such_file.py +++ b/repo_policy_sync/operations/ensure_no_such_file.py @@ -23,6 +23,7 @@ optional_string, required_string, safe_relative_path, + validate_repository_path, ) @@ -46,6 +47,7 @@ def describe_changes( ) -> 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 ( @@ -63,6 +65,7 @@ def apply( ) -> 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(): diff --git a/repo_policy_sync/operations/migrate_devcontainer_json.py b/repo_policy_sync/operations/migrate_devcontainer_json.py index c862b15..fa430a9 100644 --- a/repo_policy_sync/operations/migrate_devcontainer_json.py +++ b/repo_policy_sync/operations/migrate_devcontainer_json.py @@ -29,6 +29,7 @@ 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") @@ -111,7 +112,11 @@ def describe_changes( return () dockerfile = root / operation.dockerfile destination = root / operation.destination - migration = _migration_contents(source, operation, organization) + 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 @@ -170,12 +175,16 @@ def apply( organization: str | None = None, ) -> None: assert isinstance(operation, MigrateDevcontainerJson) - _, source = _find_source(root, operation) - if source is None: + 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 - migration = _migration_contents(source, operation, organization) + 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 @@ -212,6 +221,7 @@ def _find_source( 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: @@ -223,7 +233,10 @@ def _find_source( def _migration_contents( - source: Path, operation: MigrateDevcontainerJson, organization: str | None + 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") @@ -236,6 +249,22 @@ def _migration_contents( ) 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): diff --git a/repo_policy_sync/operations/replace_regex.py b/repo_policy_sync/operations/replace_regex.py index 16fd5ca..4eaeb5c 100644 --- a/repo_policy_sync/operations/replace_regex.py +++ b/repo_policy_sync/operations/replace_regex.py @@ -24,6 +24,7 @@ optional_string, required_string, safe_relative_path, + validate_repository_path, ) @@ -59,6 +60,7 @@ def describe_changes( ) -> 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 () @@ -78,6 +80,7 @@ def apply( ) -> None: assert isinstance(operation, ReplaceRegex) path = root / operation.path + validate_repository_path(root, path) _validate_target(path, operation) if not path.is_file(): return diff --git a/repo_policy_sync/operations/synchronize_bazel_dependencies.py b/repo_policy_sync/operations/synchronize_bazel_dependencies.py index 05df49d..37f645d 100644 --- a/repo_policy_sync/operations/synchronize_bazel_dependencies.py +++ b/repo_policy_sync/operations/synchronize_bazel_dependencies.py @@ -20,7 +20,7 @@ from pathlib import Path from typing import Any -from ..bazel import parse_bazel_version +from ..bazel import parse_bazel_version, starlark_call_ranges from ..errors import PolicyError, RepoPolicySyncError from ..models import ( BazelDependencyUpdate, @@ -34,13 +34,12 @@ required_string, safe_relative_path, string_list, + validate_repository_path, ) # Keep the complete call text so argument values can be located exactly. -_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) _NAME_ARGUMENT = re.compile(r"\bname\s*=\s*([\"'])([^\"']+)\1") _VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") -_GIT_OVERRIDE_CALL = re.compile(r"git_override\s*\((.*?)\)", re.DOTALL) _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") @@ -135,6 +134,7 @@ def describe_changes( ) -> 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] = [] @@ -168,6 +168,7 @@ def apply( ) -> 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") @@ -285,12 +286,17 @@ def _replace_build_references(text: str, pairs: tuple[tuple[str, str], ...]) -> 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"(?" + r"(?P@@?)(?P" + "|".join(re.escape(name) for name in names) - + r")(?![A-Za-z0-9_])" + + r")(?=//)" + ) + return pattern.sub( + lambda match: match.group("prefix") + replacements[match.group("module")], + text, ) - return pattern.sub(lambda match: replacements[match.group("module")], text) def _build_reference_description(pairs: tuple[tuple[str, str], ...]) -> str: @@ -396,8 +402,10 @@ def _git_override_locations( for name in _dependency_names(dependency) } locations: dict[str, _GitOverrideLocation] = {} - for call in _GIT_OVERRIDE_CALL.finditer(text): - module_name_matches = list(_MODULE_NAME_ARGUMENT.finditer(call.group(1))) + 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 ] @@ -415,37 +423,36 @@ def _git_override_locations( f"{operation.module_file} must contain at most one git_override for " f"{module_name!r}" ) - commit_matches = list(_COMMIT_ARGUMENT.finditer(call.group(1))) + 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(call.group(1))) + 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 - call_content_start = call.start(1) locations[module_name] = _GitOverrideLocation( module_name=module_name, commit=commit_match.group(2), - module_name_start=call_content_start + module_name_match.start(2), - module_name_end=call_content_start + module_name_match.end(2), - commit_start=call_content_start + commit_match.start(2), - commit_end=call_content_start + commit_match.end(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=(call_content_start + remote_match.start(2)) + remote_start=(body_start + remote_match.start(2)) if remote_match else None, - remote_end=(call_content_start + remote_match.end(2)) + remote_end=(body_start + remote_match.end(2)) if remote_match else None, - remote_insertion=call.end(1), - remote_insertion_prefix="" if call.group(1).endswith("\n") else "\n", + remote_insertion=body_end, + remote_insertion_prefix="" if body.endswith("\n") else "\n", ) return locations @@ -459,8 +466,11 @@ def _module_locations( for dependency in operation.dependencies for name in _dependency_names(dependency) } - for call in _BAZEL_DEP_CALL.finditer(text): - name_matches = list(_NAME_ARGUMENT.finditer(call.group(1))) + 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 ] @@ -479,7 +489,7 @@ def _module_locations( raise RepoPolicySyncError( f"{operation.module_file} must contain at most one bazel_dep for {name!r}" ) - version_matches = list(_VERSION_ARGUMENT.finditer(call.group(1))) + 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" @@ -491,14 +501,13 @@ def _module_locations( raise RepoPolicySyncError( f"{operation.module_file} bazel_dep for {name!r} must use X.Y.Z, found {version_text!r}" ) - call_content_start = call.start(1) locations[name] = _DependencyLocation( name=name, version=version, - name_start=call_content_start + name_match.start(2), - name_end=call_content_start + name_match.end(2), - version_start=call_content_start + version_match.start(2), - version_end=call_content_start + version_match.end(2), + 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. @@ -521,11 +530,14 @@ def _build_files( root: Path, operation: SynchronizeBazelDependencies ) -> tuple[Path, ...]: # BUILD files are selected by basename because Bazel allows them in every package. - return tuple( - path - for path in sorted(root.rglob("*")) - # Git metadata is not part of the repository content being synchronized. - if path.is_file() - and path.name in operation.build_file_names - and ".git" not in path.relative_to(root).parts - ) + paths: list[Path] = [] + for path in sorted(root.rglob("*")): + relative = path.relative_to(root) + if ".git" in relative.parts: + continue + # Validate every discovered path before filtering so a symlinked + # directory cannot be followed later when a BUILD file is read. + 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 index 837ba61..0746d23 100644 --- a/repo_policy_sync/operations/synchronize_devcontainer_version.py +++ b/repo_policy_sync/operations/synchronize_devcontainer_version.py @@ -20,6 +20,7 @@ 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 ( @@ -27,10 +28,10 @@ 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") -_BAZEL_DEP_CALL = re.compile(r"bazel_dep\s*\((.*?)\)", re.DOTALL) _NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") _VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") @@ -126,6 +127,7 @@ 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") @@ -152,12 +154,16 @@ 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 match in _BAZEL_DEP_CALL.finditer(text): - name_matches = list(_NAME_ARGUMENT.finditer(match.group(1))) + 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 ): @@ -166,12 +172,12 @@ def _module_location( f"{operation.module_file} bazel_dep for {operation.module_name!r} " "must declare name exactly once" ) - calls.append(match) + 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(calls[0].group(1))) + 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"' @@ -187,8 +193,8 @@ def _module_location( 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].start(1) + version_match.start(2) - end = calls[0].start(1) + version_match.end(2) + 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) diff --git a/repo_policy_sync/operations/synchronize_file.py b/repo_policy_sync/operations/synchronize_file.py index 81e5ecd..c4fca12 100644 --- a/repo_policy_sync/operations/synchronize_file.py +++ b/repo_policy_sync/operations/synchronize_file.py @@ -27,6 +27,7 @@ optional_string, required_string, safe_relative_path, + validate_repository_path, ) @@ -95,6 +96,7 @@ def describe_changes( ) -> 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") @@ -121,6 +123,7 @@ def apply( ) -> 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: @@ -237,7 +240,9 @@ def _merge_workflow_content( merged = _merge_matching_job_permissions(merged, source, rules) else: merged = _append_workflow_job(merged, source, rules) - return merged + # 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 _replace_top_level_section(existing: str, source: str, key: str) -> str: @@ -259,9 +264,14 @@ def _replace_top_level_section(existing: str, source: str, key: str) -> str: + 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] - + source[source_section[0] : source_section[1]] + + replacement + existing[end:] ) @@ -302,11 +312,8 @@ def _append_workflow_job( source_job_name = _job_name(source_job) existing_job_names = { - match.group(1) - for match in re.finditer( - r"(?m)^ ([^\s#][^:\n]*):[^\n]*(?:\n|$)", - existing[existing_jobs[0] : existing_jobs[1]], - ) + 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( @@ -357,7 +364,7 @@ def _merge_matching_job_permissions( def _job_name(job_block: str) -> str | None: - match = re.match(r" ([^\s#][^:\n]*):", job_block) + match = re.match(r"[ \t]+([^\s#][^:\n]*):", job_block) return match.group(1) if match is not None else None @@ -373,9 +380,7 @@ def _matching_job_location( jobs_section: str, rules: tuple[tuple[str, tuple[int, int, int]], ...], ) -> tuple[int, int] | None: - job_lines = list( - re.finditer(r"(?m)^ ([^\s#][^:\n]*):[^\n]*(?:\n|$)", jobs_section) - ) + job_lines = _mapping_entries(jobs_section) for index, match in enumerate(job_lines): end = ( job_lines[index + 1].start() @@ -389,15 +394,49 @@ def _matching_job_location( def _nested_job_section(text: str, key: str) -> tuple[int, int] | None: - lines = list(re.finditer(r"(?m)^ ([^\s#][^:\n]*):[^\n]*(?:\n|$)", text)) + lines = _mapping_entries(text, nested=True) for index, match in enumerate(lines): - if match.group(1).strip().strip("\"'") != key: + 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: diff --git a/repo_policy_sync/tests/test_cli.py b/repo_policy_sync/tests/test_cli.py index d093dfb..7b645f6 100644 --- a/repo_policy_sync/tests/test_cli.py +++ b/repo_policy_sync/tests/test_cli.py @@ -211,6 +211,46 @@ def test_recreate_selects_only_the_requested_bundled_policy( 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( + ( + "--org", + "eclipse-score", + "--repo", + "reference_integration", + "--policy-dir", + str(policy_directory), + "--policy", + "target", + "--apply", + "--recreate", + "--quiet", + ) + ) + == 0 + ) + + assert loaded_paths == [target] + + def test_bundled_policies_do_not_require_a_local_policy_directory( monkeypatch, tmp_path: Path ) -> None: diff --git a/repo_policy_sync/tests/test_engine.py b/repo_policy_sync/tests/test_engine.py index bdc68fd..31d158e 100644 --- a/repo_policy_sync/tests/test_engine.py +++ b/repo_policy_sync/tests/test_engine.py @@ -22,9 +22,11 @@ from repo_policy_sync.models import ( AfterApplyCommand, BazelDependencyUpdate, + BazelDependencyCondition, BazelCondition, Change, EnsureLine, + EnsureBazelDependency, EnsureMinimumVersion, EnsureNoSuchFile, FileContainsCondition, @@ -77,6 +79,101 @@ def test_policy_does_not_apply_without_direct_dependency(tmp_path: Path) -> None 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") @@ -373,6 +470,42 @@ def test_synchronize_bazel_dependencies_preserves_override_for_newer_version( 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: @@ -588,6 +721,41 @@ def test_synchronize_workflow_inserts_missing_name_and_preserves_jobs( assert " docs:\n" in result +def test_synchronize_workflow_keeps_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_workflow_rejects_job_id_collision(tmp_path: Path) -> None: target = tmp_path / ".github/workflows/docs.yml" target.parent.mkdir(parents=True) @@ -624,6 +792,46 @@ def test_synchronize_workflow_rejects_job_id_collision(tmp_path: Path) -> None: apply_policy(tmp_path, policy) +def test_synchronize_workflow_rejects_four_space_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_workflow_preserves_publish_permissions_on_matching_job( tmp_path: Path, ) -> None: @@ -698,10 +906,12 @@ def test_after_apply_regenerates_existing_conditional_file( ) calls: list[tuple[tuple[str, ...], Path]] = [] - def run(command, *, cwd, check, capture_output, text): + 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) @@ -735,11 +945,12 @@ def test_force_after_apply_runs_for_an_already_compliant_policy( ), ) - def run(command, *, cwd, check, capture_output, text): + 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) @@ -969,10 +1180,11 @@ def test_after_apply_changed_path_guard_only_regenerates_lock_after_module_chang lock_file = tmp_path / "MODULE.bazel.lock" calls: list[tuple[str, ...]] = [] - def run(command, *, cwd, check, capture_output, text): + 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") @@ -1022,3 +1234,64 @@ def test_devcontainer_migration_adds_copyright_only_for_eclipse_score( config = (repository / ".devcontainer/devcontainer.json").read_text() assert '"dockerfile": "Dockerfile"' in config assert '"context"' not in config + + +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() diff --git a/repo_policy_sync/tests/test_github.py b/repo_policy_sync/tests/test_github.py index 839c5de..f304105 100644 --- a/repo_policy_sync/tests/test_github.py +++ b/repo_policy_sync/tests/test_github.py @@ -793,6 +793,7 @@ def test_close_pull_request_uses_gh(monkeypatch) -> None: "https://github.example/owner/repo/pull/1", "--repo", "owner/repo", + "--delete-branch", ] ] @@ -1039,7 +1040,13 @@ def run(command: list[str]) -> str: "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", + }, ] ) diff --git a/uv.lock b/uv.lock index 8f1127b..927c891 100644 --- a/uv.lock +++ b/uv.lock @@ -243,7 +243,7 @@ wheels = [ [[package]] name = "tools" -version = "0.0.0" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "bazel-runfiles" }, From 21b7873de8165ad2e934396565ab7122ade31dff Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 17:24:10 +0200 Subject: [PATCH 09/12] format --- repo_policy_sync/engine.py | 4 +++- .../operations/synchronize_bazel_dependencies.py | 8 ++------ repo_policy_sync/operations/synchronize_file.py | 16 +++------------- repo_policy_sync/tests/test_engine.py | 16 +++++++--------- 4 files changed, 15 insertions(+), 29 deletions(-) diff --git a/repo_policy_sync/engine.py b/repo_policy_sync/engine.py index af45481..b2de31d 100644 --- a/repo_policy_sync/engine.py +++ b/repo_policy_sync/engine.py @@ -114,7 +114,9 @@ def _run_after_apply_command(root: Path, command: tuple[str, ...]) -> None: if key in _REDUCED_ENVIRONMENT_KEYS or key.startswith("LC_") } try: - with tempfile.TemporaryDirectory(prefix="repo-policy-sync-after-apply-") as home: + with tempfile.TemporaryDirectory( + prefix="repo-policy-sync-after-apply-" + ) as home: environment.update( { "HOME": home, diff --git a/repo_policy_sync/operations/synchronize_bazel_dependencies.py b/repo_policy_sync/operations/synchronize_bazel_dependencies.py index 37f645d..2c57048 100644 --- a/repo_policy_sync/operations/synchronize_bazel_dependencies.py +++ b/repo_policy_sync/operations/synchronize_bazel_dependencies.py @@ -445,12 +445,8 @@ def _git_override_locations( 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_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", ) diff --git a/repo_policy_sync/operations/synchronize_file.py b/repo_policy_sync/operations/synchronize_file.py index c4fca12..ebec6fa 100644 --- a/repo_policy_sync/operations/synchronize_file.py +++ b/repo_policy_sync/operations/synchronize_file.py @@ -269,11 +269,7 @@ def _replace_top_level_section(existing: str, source: str, key: str) -> str: # 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:] - ) + return existing[:start] + replacement + existing[end:] def _remove_top_level_section(text: str, key: str) -> str: @@ -403,9 +399,7 @@ def _nested_job_section(text: str, key: str) -> tuple[int, int] | None: return None -def _mapping_entries( - text: str, *, nested: bool = False -) -> list[re.Match[str]]: +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 @@ -430,11 +424,7 @@ def _mapping_entries( target_width = min(child_widths) else: target_width = min(widths) - return [ - match - for match, width in zip(candidates, widths) - if width == target_width - ] + return [match for match, width in zip(candidates, widths) if width == target_width] def _preserved_ref( diff --git a/repo_policy_sync/tests/test_engine.py b/repo_policy_sync/tests/test_engine.py index 31d158e..ce3b1b2 100644 --- a/repo_policy_sync/tests/test_engine.py +++ b/repo_policy_sync/tests/test_engine.py @@ -503,7 +503,9 @@ def test_synchronize_bazel_dependencies_ignores_commented_override( module = module_file.read_text() assert 'commit = "baseline-commit"' in module - assert '# git_override(module_name = "score_baselibs", commit = "commented"' in module + assert ( + '# git_override(module_name = "score_baselibs", commit = "commented"' in module + ) def test_bazel_dependency_policy_preserves_newer_baselibs_override( @@ -727,9 +729,7 @@ def test_synchronize_workflow_keeps_sections_separated_without_source_newline( 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" + "name: Local\non: [push]\njobs:\n local:\n runs-on: ubuntu-latest\n" ) policy = Policy( "example", @@ -798,9 +798,7 @@ def test_synchronize_workflow_rejects_four_space_job_id_collision( 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" + "name: Local\non: [push]\njobs:\n docs:\n runs-on: ubuntu-latest\n" ) policy = Policy( "example", @@ -1245,10 +1243,10 @@ def test_devcontainer_migration_rejects_root_config_with_relative_paths( / "policy.yml" ) (tmp_path / ".devcontainer.json").write_text( - '{\n' + "{\n" ' "image": "ghcr.io/eclipse-score/devcontainer:v1.9.0",\n' ' "mounts": ["source=./cache,target=/cache"]\n' - '}\n' + "}\n" ) with pytest.raises(RepoPolicySyncError, match="relative paths"): From e2f67052a3203d450d3ea962c36b3732ba9055e7 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 17:51:49 +0200 Subject: [PATCH 10/12] fix simple solution --- .github/workflows/tests.yml | 5 +- pyproject.toml | 1 + .../operations/migrate_devcontainer_json.py | 56 ++--------- .../synchronize_bazel_dependencies.py | 7 +- .../policy.yml | 2 +- repo_policy_sync/tests/test_engine.py | 74 ++++++++++++++ uv.lock | 99 +++++++++++++++++++ 7 files changed, 190 insertions(+), 54 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f7746b8..787e94d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,4 +45,7 @@ jobs: - name: Build the Python wheel run: uv build --wheel - name: Smoke-test the installed Repository Policy Sync CLI - run: uv run --with dist/*.whl score-repo-policy-sync --help + 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/pyproject.toml b/pyproject.toml index 0e2ce14..4cc43ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "bazel-runfiles==1.3.0", + "pre-commit>=4.0.0", "pydantic>=2.0", "pyyaml>=6.0", ] diff --git a/repo_policy_sync/operations/migrate_devcontainer_json.py b/repo_policy_sync/operations/migrate_devcontainer_json.py index fa430a9..625a6eb 100644 --- a/repo_policy_sync/operations/migrate_devcontainer_json.py +++ b/repo_policy_sync/operations/migrate_devcontainer_json.py @@ -34,8 +34,8 @@ _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]*)"image"\s*:\s*"(?P[^"]+)"' - r"(?P,?)(?P[ \t]*(?://[^\r\n]*)?(?:\r?\n|$))" + r'(?m)(?P^[ \t]*|(?<=[{,])[ \t]*)"image"\s*:\s*"' + r'(?P[^"\\]*(?:\\.[^"\\]*)*)"(?P,?)' ) @@ -293,7 +293,7 @@ def _migration_contents( replacement = ( f'{indent}"build": {{\n' f'{indent} "dockerfile": {dockerfile}\n' - f"{indent}}}{match.group('comma')}{match.group('tail')}" + f"{indent}}}{match.group('comma')}" ) destination_contents = text[: match.start()] + replacement + text[match.end() :] copyright_header = ( @@ -310,54 +310,10 @@ def _migration_contents( def _strip_jsonc(text: str) -> str: - """Remove JSONC comments and trailing commas while preserving strings.""" + """Remove simple full-line comments and trailing commas.""" - result: list[str] = [] - in_string = False - escaped = False - in_line_comment = False - in_block_comment = False - index = 0 - while index < len(text): - character = text[index] - following = text[index + 1] if index + 1 < len(text) else "" - if in_line_comment: - if character in "\r\n": - in_line_comment = False - result.append(character) - else: - result.append(" ") - elif in_block_comment: - if character == "*" and following == "/": - in_block_comment = False - result.extend((" ", " ")) - index += 1 - else: - result.append("\n" if character in "\r\n" else " ") - elif in_string: - result.append(character) - if escaped: - escaped = False - elif character == "\\": - escaped = True - elif character == '"': - in_string = False - elif character == '"': - in_string = True - result.append(character) - elif character == "/" and following == "/": - in_line_comment = True - result.extend((" ", " ")) - index += 1 - elif character == "/" and following == "*": - in_block_comment = True - result.extend((" ", " ")) - index += 1 - else: - result.append(character) - index += 1 - - return re.sub(r",\s*([}\]])", r"\1", "".join(result)) + 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: diff --git a/repo_policy_sync/operations/synchronize_bazel_dependencies.py b/repo_policy_sync/operations/synchronize_bazel_dependencies.py index 2c57048..32e621e 100644 --- a/repo_policy_sync/operations/synchronize_bazel_dependencies.py +++ b/repo_policy_sync/operations/synchronize_bazel_dependencies.py @@ -531,8 +531,11 @@ def _build_files( relative = path.relative_to(root) if ".git" in relative.parts: continue - # Validate every discovered path before filtering so a symlinked - # directory cannot be followed later when a BUILD file is read. + # 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) diff --git a/repo_policy_sync/policies/score-devcontainer-standardization/policy.yml b/repo_policy_sync/policies/score-devcontainer-standardization/policy.yml index 609b724..afa5d9e 100644 --- a/repo_policy_sync/policies/score-devcontainer-standardization/policy.yml +++ b/repo_policy_sync/policies/score-devcontainer-standardization/policy.yml @@ -33,7 +33,7 @@ ensure: rationale: Provide the standard SCORE tool launcher in repositories using the SCORE devcontainer. - type: replace_regex path: .pre-commit-config.yaml - pattern: '(?:tools|\.devcontainer)/run_tool\.sh' + pattern: '(? 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: @@ -1234,6 +1255,26 @@ def test_devcontainer_migration_adds_copyright_only_for_eclipse_score( 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: @@ -1293,3 +1334,36 @@ def test_operations_reject_symlink_to_path_outside_checkout(tmp_path: Path) -> N 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/uv.lock b/uv.lock index 927c891..f972bf3 100644 --- a/uv.lock +++ b/uv.lock @@ -19,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" @@ -28,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" @@ -37,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" @@ -46,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" @@ -55,6 +109,22 @@ 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" @@ -170,6 +240,18 @@ 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" @@ -247,6 +329,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "bazel-runfiles" }, + { name = "pre-commit" }, { name = "pydantic" }, { name = "pyyaml" }, ] @@ -260,6 +343,7 @@ dev = [ [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" }, ] @@ -290,3 +374,18 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac 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" }, +] From 75bf2d4d2ad7feffd65b64bc3e381ccbd7e10d32 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 18:20:48 +0200 Subject: [PATCH 11/12] update docs jobs --- .../docs-publish.yml | 13 ++++++++---- .../score-docs-workflow-alignment/docs.yml | 20 ++++++++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) 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 index 3d963ed..ed861c1 100644 --- a/repo_policy_sync/policies/score-docs-workflow-alignment/docs-publish.yml +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/docs-publish.yml @@ -10,20 +10,25 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* + name: Publish Documentation + on: workflow_run: - workflows: ["Documentation CI"] + workflows: ["Documentation"] 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 + uses: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 # v0.0.3 permissions: actions: read 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 index cb82203..32ed45d 100644 --- a/repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml @@ -10,18 +10,24 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -name: Documentation CI + +name: Documentation + +permissions: + contents: read + on: pull_request: + types: [opened, reopened, synchronize] push: branches: - main - tags: - - "v*" - release: - types: [published] merge_group: types: [checks_requested] + release: + types: [published] + workflow_dispatch: + jobs: - docs: - uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 + docs-build: + uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@8d80e8df150cae21d53cbc8031d0f970648f7a67 # v0.0.3 From 00f1d6330451db6640b2e5f887292f3848da922e Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 25 Aug 2026 20:41:18 +0200 Subject: [PATCH 12/12] wip --- repo_policy_sync/README.md | 24 +- repo_policy_sync/cli.py | 142 ++++-- repo_policy_sync/config.py | 4 - repo_policy_sync/docs/how-to/run-a-policy.md | 34 +- repo_policy_sync/docs/reference/cli.md | 21 +- .../docs/reference/configuration.md | 2 - .../docs/reference/policy-format.md | 41 +- .../docs/tutorials/first-policy.md | 2 +- repo_policy_sync/engine.py | 36 ++ repo_policy_sync/models.py | 15 + repo_policy_sync/operations/README.md | 1 + repo_policy_sync/operations/__init__.py | 2 + .../operations/synchronize_file.py | 59 ++- .../operations/synchronize_workflow.py | 414 ++++++++++++++++++ .../after/.github/workflows/docs-publish.yml | 4 +- .../after/.github/workflows/docs.yml | 7 + .../docs-publish.yml | 9 +- .../score-docs-workflow-alignment/docs.yml | 3 + .../after/.github/workflows/docs-publish.yml | 22 +- .../after/.github/workflows/docs.yml | 20 +- .../after/.github/workflows/docs-publish.yml | 22 +- .../after/.github/workflows/docs.yml | 17 +- .../score-docs-workflow-alignment/policy.yml | 34 +- repo_policy_sync/samples.py | 201 +++++++++ repo_policy_sync/tests/test_cli.py | 89 +++- repo_policy_sync/tests/test_config.py | 2 - repo_policy_sync/tests/test_engine.py | 59 ++- repo_policy_sync/tests/test_samples.py | 92 ++++ .../tests/test_synchronize_workflow.py | 120 +++++ 29 files changed, 1323 insertions(+), 175 deletions(-) create mode 100644 repo_policy_sync/operations/synchronize_workflow.py create mode 100644 repo_policy_sync/samples.py create mode 100644 repo_policy_sync/tests/test_samples.py create mode 100644 repo_policy_sync/tests/test_synchronize_workflow.py diff --git a/repo_policy_sync/README.md b/repo_policy_sync/README.md index 0374874..d2af8ea 100644 --- a/repo_policy_sync/README.md +++ b/repo_policy_sync/README.md @@ -37,14 +37,20 @@ 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 --org eclipse-score +uv run score-repo-policy-sync plan --org eclipse-score # Add another local policy directory when needed. -uv run score-repo-policy-sync --org eclipse-score \ +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 --org eclipse-score --apply +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 @@ -53,7 +59,7 @@ 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 --org eclipse-score --apply --allow-dirty-pr +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 @@ -63,13 +69,13 @@ 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 \ +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 \ +uv run score-repo-policy-sync plan \ --org etas \ --repo reference_integration ``` @@ -77,7 +83,7 @@ uv run score-repo-policy-sync \ To exclude a bundled policy for a repository or rollout: ```bash -uv run score-repo-policy-sync \ +uv run score-repo-policy-sync plan \ --org etas \ --exclude-bundled-policy minimum-bazel-version ``` @@ -115,8 +121,8 @@ 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 --org eclipse-score --repo reference_integration \ - --policy minimum-bazel-version --apply --recreate +uv run score-repo-policy-sync apply --org eclipse-score --repo reference_integration \ + --policy minimum-bazel-version --recreate ``` ## Documentation diff --git a/repo_policy_sync/cli.py b/repo_policy_sync/cli.py index 320d5a6..22a2ae5 100644 --- a/repo_policy_sync/cli.py +++ b/repo_policy_sync/cli.py @@ -33,6 +33,7 @@ ) 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: @@ -40,15 +41,56 @@ def create_parser() -> argparse.ArgumentParser: prog="score-repo-policy-sync", description="Evaluate and remediate repository policies across a GitHub organization.", epilog=( - "All policy options except --config, --json-output, and --markdown-output " - "may also be set in the TOML configuration. " + "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).", @@ -57,7 +99,7 @@ def create_parser() -> argparse.ArgumentParser: "--policy", action="append", metavar="NAME", - help="Select one local policy by name. Repeat to select policies; defaults to all local policies.", + help="Select one policy by name. Repeat to select policies; defaults to all available policies.", ) typical.add_argument( "--repo", @@ -65,29 +107,24 @@ def create_parser() -> argparse.ArgumentParser: default=None, help="Exact repository name to include. Repeat to include more repositories.", ) - typical.add_argument( - "--apply", - action=argparse.BooleanOptionalAction, - default=None, - help="Commit, push, and create or update pull requests for required changes.", - ) rare.add_argument( "--config", type=Path, help="TOML configuration file (default: score-repo-policy-sync.toml if present).", ) - 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.", - ) + 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", @@ -101,21 +138,6 @@ def create_parser() -> argparse.ArgumentParser: metavar="NAME", help="Exclude one bundled SCORE policy by name. Repeat to exclude policies.", ) - rare.add_argument( - "--recreate", - action=argparse.BooleanOptionalAction, - default=None, - help="Recreate one existing policy-owned pull request from the current default branch (requires --apply).", - ) - rare.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." - ), - ) rare.add_argument( "--quiet", action=argparse.BooleanOptionalAction, @@ -146,7 +168,6 @@ def create_parser() -> argparse.ArgumentParser: f"(default: {DEFAULT_POLICY_WORKERS}, the available CPU count)." ), ) - return parser def main(argv: Sequence[str] | None = None) -> int: @@ -163,15 +184,28 @@ def main(argv: Sequence[str] | None = None) -> int: repository_names = tuple( args.repo if args.repo is not None else (config.repositories or ()) ) - apply = args.apply if args.apply is not None else (config.apply or False) - recreate = ( - args.recreate if args.recreate is not None else (config.recreate or False) - ) + 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 args.allow_dirty_pr is not None - else (config.allow_dirty_pr or False) + 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 @@ -194,8 +228,6 @@ def main(argv: Sequence[str] | None = None) -> int: else config.exclude_bundled_policies ) if recreate: - if not apply: - parser.error("--recreate requires --apply") if len(repository_names) != 1: parser.error("--recreate requires exactly one --repo") if len(policy_names) != 1: @@ -250,13 +282,27 @@ def main(argv: Sequence[str] | None = None) -> int: ) 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=apply, + apply=applying, recreate=recreate, allow_dirty_pr=allow_dirty_pr, sync_workers=sync_workers, @@ -288,7 +334,7 @@ def main(argv: Sequence[str] | None = None) -> int: print(output) if report.summary.sync_failures or report.summary.evaluation_failures: return 2 - return 1 if report.summary.drifted and not apply else 0 + return 1 if report.summary.drifted and not applying else 0 def _write_progress(message: str) -> None: diff --git a/repo_policy_sync/config.py b/repo_policy_sync/config.py index dc862b0..7246c4e 100644 --- a/repo_policy_sync/config.py +++ b/repo_policy_sync/config.py @@ -33,7 +33,6 @@ class PolicySyncConfig: org: str | None = None policies: tuple[str, ...] | None = None repositories: tuple[str, ...] | None = None - apply: bool | None = None policy_directories: tuple[Path, ...] | None = None exclude_bundled_policies: tuple[str, ...] = () recreate: bool | None = None @@ -88,7 +87,6 @@ def load_config(path: Path | None = None) -> PolicySyncConfig: "org", "policies", "repos", - "apply", "policy_dirs", "exclude_bundled_policies", "recreate", @@ -107,7 +105,6 @@ def load_config(path: Path | None = None) -> PolicySyncConfig: org = _optional_string(section, "org", config_path) policies = _optional_string_list(section, "policies", config_path) repositories = _optional_string_list(section, "repos", config_path) - apply = _optional_bool(section, "apply", config_path) policy_directories = None if "policy_dirs" in section: @@ -139,7 +136,6 @@ def load_config(path: Path | None = None) -> PolicySyncConfig: org=org, policies=policies, repositories=repositories, - apply=apply, policy_directories=policy_directories, exclude_bundled_policies=exclude_bundled_policies, recreate=recreate, diff --git a/repo_policy_sync/docs/how-to/run-a-policy.md b/repo_policy_sync/docs/how-to/run-a-policy.md index 82772dc..89cccf5 100644 --- a/repo_policy_sync/docs/how-to/run-a-policy.md +++ b/repo_policy_sync/docs/how-to/run-a-policy.md @@ -17,7 +17,7 @@ Use plan mode first. It refreshes disposable local checkouts and reports drift, but never changes remote repositories: ```bash -uv run score-repo-policy-sync --org eclipse-score +uv run score-repo-policy-sync plan --org eclipse-score ``` The bundled SCORE policies are always loaded. In addition, policies are loaded @@ -26,7 +26,7 @@ The directory layout and policy format are the same for local and bundled policies: ```bash -uv run score-repo-policy-sync \ +uv run score-repo-policy-sync plan \ --org etas ``` @@ -36,7 +36,7 @@ option to combine local policy directories. Exclude a bundled SCORE policy with `--exclude-bundled-policy`: ```bash -uv run score-repo-policy-sync \ +uv run score-repo-policy-sync plan \ --org eclipse-score \ --repo reference_integration \ --exclude-bundled-policy minimum-bazel-version @@ -53,11 +53,24 @@ 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 \ +uv run score-repo-policy-sync plan \ --org eclipse-score \ --repo reference_integration \ --policy-dir policies \ @@ -68,12 +81,11 @@ 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 \ +uv run score-repo-policy-sync apply \ --org eclipse-score \ --repo reference_integration \ --policy-dir policies \ - --policy minimum-bazel-version \ - --apply + --policy minimum-bazel-version ``` ## Authentication and permissions @@ -103,7 +115,7 @@ permissions: 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 not pass `--apply`. +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 @@ -116,7 +128,7 @@ 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 \ +uv run score-repo-policy-sync plan \ --org eclipse-score \ --json-output policy-report.json ``` @@ -135,7 +147,7 @@ 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 +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. @@ -156,5 +168,5 @@ 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 +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 index 176881d..806cffe 100644 --- a/repo_policy_sync/docs/reference/cli.md +++ b/repo_policy_sync/docs/reference/cli.md @@ -14,14 +14,23 @@ # CLI reference ```text -score-repo-policy-sync [--org ORGANIZATION] [OPTIONS] +score-repo-policy-sync COMMAND [OPTIONS] ``` -Policy options may also be set in the TOML configuration. Explicit +`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 | @@ -29,7 +38,6 @@ are CLI-only. | `--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. | -| `--apply`, `--no-apply` | Commit, push, and create or update policy-owned pull requests. Without this flag, the command only plans changes. | | *(stdout)* | Always prints the terminal policy-evaluation table. | ## Rare @@ -41,7 +49,7 @@ are CLI-only. | `--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`, `--no-recreate` | Rebuild one existing policy-owned pull request from its repository's current default branch. Requires `--apply`, exactly one `--repo`, and exactly one `--policy`. | +| `--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. | @@ -52,6 +60,7 @@ are CLI-only. | `--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. @@ -76,4 +85,8 @@ 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 index cc5b64b..81bbe33 100644 --- a/repo_policy_sync/docs/reference/configuration.md +++ b/repo_policy_sync/docs/reference/configuration.md @@ -25,7 +25,6 @@ continues with its defaults. `--config`, `--json-output`, and org = "eclipse-score" policies = ["my-local-policy"] repos = ["reference_integration"] -apply = false # Relative paths are resolved relative to this TOML file. policy_dirs = ["policies", "shared-policies"] @@ -50,7 +49,6 @@ The TOML keys map to the corresponding CLI options as follows: | `org` | `--org` | | `policies` | repeated `--policy` | | `repos` | repeated `--repo` | -| `apply` | `--apply` / `--no-apply` | | `policy_dirs` | repeated `--policy-dir` | | `exclude_bundled_policies` | repeated `--exclude-bundled-policy` | | `recreate` | `--recreate` / `--no-recreate` | diff --git a/repo_policy_sync/docs/reference/policy-format.md b/repo_policy_sync/docs/reference/policy-format.md index 0f4b8cf..825b294 100644 --- a/repo_policy_sync/docs/reference/policy-format.md +++ b/repo_policy_sync/docs/reference/policy-format.md @@ -197,6 +197,40 @@ 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 @@ -225,9 +259,10 @@ preserve_reusable_workflow_refs: 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 top-level `permissions` block is removed so the -shared documentation build remains unprivileged; jobs needing permissions -must declare them at job level. +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` diff --git a/repo_policy_sync/docs/tutorials/first-policy.md b/repo_policy_sync/docs/tutorials/first-policy.md index b296578..6cfdfca 100644 --- a/repo_policy_sync/docs/tutorials/first-policy.md +++ b/repo_policy_sync/docs/tutorials/first-policy.md @@ -66,7 +66,7 @@ Use plan mode against one known repository before enabling apply mode for an organization: ```bash -uv run score-repo-policy-sync \ +uv run score-repo-policy-sync plan \ --org eclipse-score \ --repo example-repository \ --policy ensure-build-directory-ignored diff --git a/repo_policy_sync/engine.py b/repo_policy_sync/engine.py index b2de31d..cb7f487 100644 --- a/repo_policy_sync/engine.py +++ b/repo_policy_sync/engine.py @@ -70,6 +70,42 @@ def evaluate_policy( 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, diff --git a/repo_policy_sync/models.py b/repo_policy_sync/models.py index 1c2d72d..ffecbb5 100644 --- a/repo_policy_sync/models.py +++ b/repo_policy_sync/models.py @@ -155,6 +155,20 @@ class SynchronizeFile: 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.""" @@ -178,6 +192,7 @@ class MigrateDevcontainerJson: | SynchronizeDevcontainerVersion | SynchronizeBazelDependencies | SynchronizeFile + | SynchronizeWorkflow | MigrateDevcontainerJson ) diff --git a/repo_policy_sync/operations/README.md b/repo_policy_sync/operations/README.md index 498155c..d981bcf 100644 --- a/repo_policy_sync/operations/README.md +++ b/repo_policy_sync/operations/README.md @@ -35,6 +35,7 @@ authoritative source for the complete schema, validation rules, and examples. | `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. diff --git a/repo_policy_sync/operations/__init__.py b/repo_policy_sync/operations/__init__.py index 7ae139d..cf2bc60 100644 --- a/repo_policy_sync/operations/__init__.py +++ b/repo_policy_sync/operations/__init__.py @@ -28,6 +28,7 @@ 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): @@ -63,6 +64,7 @@ def apply( 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. diff --git a/repo_policy_sync/operations/synchronize_file.py b/repo_policy_sync/operations/synchronize_file.py index ebec6fa..bab510c 100644 --- a/repo_policy_sync/operations/synchronize_file.py +++ b/repo_policy_sync/operations/synchronize_file.py @@ -216,35 +216,46 @@ def _merge_workflow_content( merged = existing for section in ("name", "on"): merged = _replace_top_level_section(merged, source, section) - # The shared build workflow is intentionally unprivileged. Existing - # top-level write permissions would apply to every job in this workflow. - merged = _remove_top_level_section(merged, "permissions") - - if rules: - 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 not None: - existing_jobs = _top_level_section(merged, "jobs") - existing_job = ( - _matching_job_location( - merged[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: - merged = _merge_matching_job_permissions(merged, source, rules) - else: - merged = _append_workflow_job(merged, source, rules) + # 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) 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/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 index 3d963ed..195606f 100644 --- 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 @@ -11,10 +11,12 @@ # 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' @@ -22,7 +24,7 @@ jobs: with: deployment_type: workflow permissions: - actions: read + actions: write contents: write id-token: write pages: 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 index 900aab9..1061404 100644 --- 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 @@ -10,6 +10,9 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +permissions: + contents: read + name: Documentation CI on: pull_request: @@ -22,8 +25,12 @@ on: 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/docs-publish.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/docs-publish.yml index ed861c1..c3b4369 100644 --- a/repo_policy_sync/policies/score-docs-workflow-alignment/docs-publish.yml +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/docs-publish.yml @@ -20,10 +20,15 @@ on: jobs: docs-publish: - if: github.event.workflow_run.conclusion == 'success' + # 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: read + actions: write contents: write id-token: write pages: write diff --git a/repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml index 32ed45d..635f737 100644 --- a/repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/docs.yml @@ -31,3 +31,6 @@ on: 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 index 3d963ed..c3b4369 100644 --- 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 @@ -10,20 +10,30 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* + name: Publish Documentation + on: workflow_run: - workflows: ["Documentation CI"] + workflows: ["Documentation"] 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 + # 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: read + 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 index 26933ad..de9cd88 100644 --- 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 @@ -10,18 +10,23 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -name: Documentation CI +name: Documentation on: - pull_request: + pull_request_target: + types: [opened, reopened, synchronize] push: branches: - main - tags: - - "v*" - release: - types: [published] + 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 @@ -32,3 +37,6 @@ jobs: 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/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 index 3d963ed..c3b4369 100644 --- 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 @@ -10,20 +10,30 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* + name: Publish Documentation + on: workflow_run: - workflows: ["Documentation CI"] + workflows: ["Documentation"] 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 + # 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: read + 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 index 6e5438b..2ffa7ff 100644 --- 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 @@ -10,18 +10,25 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -name: Documentation CI +permissions: + contents: read + +name: Documentation on: + pull_request_target: pull_request: + types: [opened, reopened, synchronize] push: branches: - main - tags: - - "v*" - release: - types: [published] 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/policy.yml b/repo_policy_sync/policies/score-docs-workflow-alignment/policy.yml index e091e81..860fade 100644 --- a/repo_policy_sync/policies/score-docs-workflow-alignment/policy.yml +++ b/repo_policy_sync/policies/score-docs-workflow-alignment/policy.yml @@ -16,23 +16,23 @@ description: | by cicd-workflows. Keep repository-specific workflow refs when they are at least the policy baseline or cannot be safely ordered. when: - file_contains: - path: .github/workflows/docs.yml - pattern: '(?m)^\s*uses:\s+eclipse-score/cicd-workflows/\.github/workflows/docs\.yml@' + 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_file - path: .github/workflows/docs.yml + - type: synchronize_workflow source: docs.yml - preserve_workflow_content: true - preserve_reusable_workflow_refs: - - workflow: eclipse-score/cicd-workflows/.github/workflows/docs.yml - minimum_version: 0.0.3 + 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. - - type: synchronize_file - path: .github/workflows/docs-publish.yml - source: docs-publish.yml - preserve_workflow_content: true - preserve_reusable_workflow_refs: - - workflow: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml - minimum_version: 0.0.3 - rationale: Publish only successful documentation artifacts with the documented workflow. 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/tests/test_cli.py b/repo_policy_sync/tests/test_cli.py index 7b645f6..dde2950 100644 --- a/repo_policy_sync/tests/test_cli.py +++ b/repo_policy_sync/tests/test_cli.py @@ -18,6 +18,7 @@ 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: @@ -44,13 +45,13 @@ def _empty_report() -> RunReport: @pytest.mark.parametrize( "argv, message", [ - (("--org", "eclipse-score", "--recreate"), "--recreate requires --apply"), + (("plan", "--org", "eclipse-score", "--recreate"), "unrecognized arguments"), ( - ("--org", "eclipse-score", "--apply", "--recreate", "--policy", "example"), + ("apply", "--org", "eclipse-score", "--recreate", "--policy", "example"), "exactly one --repo", ), ( - ("--org", "eclipse-score", "--apply", "--recreate", "--repo", "example"), + ("apply", "--org", "eclipse-score", "--recreate", "--repo", "example"), "exactly one --policy", ), ], @@ -88,6 +89,7 @@ def test_default_output_is_a_table(monkeypatch, capsys) -> None: assert ( cli.main( ( + "plan", "--org", "eclipse-score", "--policy-dir", @@ -120,6 +122,7 @@ def test_all_reports_can_be_written_from_one_run( assert ( cli.main( ( + "plan", "--org", "eclipse-score", "--policy-dir", @@ -156,6 +159,7 @@ def test_json_report_requests_pull_request_status( assert ( cli.main( ( + "plan", "--org", "eclipse-score", "--policy-dir", @@ -172,6 +176,38 @@ def test_json_report_requests_pull_request_status( 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: @@ -191,13 +227,13 @@ def test_recreate_selects_only_the_requested_bundled_policy( assert ( cli.main( ( + "apply", "--org", "eclipse-score", "--repo", "reference_integration", "--policy", "minimum-bazel-version", - "--apply", "--recreate", "--quiet", ) @@ -232,6 +268,7 @@ def test_recreate_does_not_load_unrelated_local_policies( assert ( cli.main( ( + "apply", "--org", "eclipse-score", "--repo", @@ -240,7 +277,6 @@ def test_recreate_does_not_load_unrelated_local_policies( str(policy_directory), "--policy", "target", - "--apply", "--recreate", "--quiet", ) @@ -265,6 +301,7 @@ def test_bundled_policies_do_not_require_a_local_policy_directory( assert ( cli.main( ( + "plan", "--org", "etas-eng", "--repo", @@ -286,6 +323,7 @@ def test_bundled_policies_do_not_require_a_local_policy_directory( assert ( cli.main( ( + "plan", "--org", "etas-eng", "--repo", @@ -313,6 +351,7 @@ def test_bundled_policy_selected_from_bundled_directory_is_not_loaded_twice( assert ( cli.main( ( + "plan", "--org", "eclipse-score", "--repo", @@ -321,7 +360,6 @@ def test_bundled_policy_selected_from_bundled_directory_is_not_loaded_twice( "repo_policy_sync/policies", "--policy", "minimum-bazel-version", - "--no-apply", "--quiet", ) ) @@ -341,7 +379,6 @@ def test_config_values_are_overridden_by_explicit_cli_values( """[score-repo-policy-sync] org = "config-org" repos = ["config-repo"] -apply = true policy_dirs = [] exclude_bundled_policies = ["minimum-bazel-version"] recreate = false @@ -367,13 +404,13 @@ def test_config_values_are_overridden_by_explicit_cli_values( assert ( cli.main( ( + "apply", "--config", str(config_path), "--org", "cli-org", "--repo", "cli-repo", - "--no-apply", "--no-allow-dirty-pr", "--exclude-bundled-policy", "score-devcontainer-dockerfile-migration", @@ -390,7 +427,7 @@ def test_config_values_are_overridden_by_explicit_cli_values( ) assert observed["org"] == "cli-org" assert observed["repository_names"] == ("cli-repo",) - assert observed["apply"] is False + assert observed["apply"] is True assert observed["allow_dirty_pr"] is False assert observed["sync_workers"] == 7 assert observed["policy_workers"] == 8 @@ -404,14 +441,14 @@ def test_config_values_are_overridden_by_explicit_cli_values( def test_configurable_defaults_are_left_unset_for_config_merging() -> None: - args = cli.create_parser().parse_args(("--org", "eclipse-score")) + 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(("--org", "eclipse-score")) + args = cli.create_parser().parse_args(("plan", "--org", "eclipse-score")) assert args.policy_dir is None assert args.config is None @@ -420,7 +457,7 @@ def test_policy_directory_defaults_to_current_working_directory() -> None: def test_dirty_pull_requests_can_be_enabled() -> None: args = cli.create_parser().parse_args( - ("--org", "eclipse-score", "--allow-dirty-pr") + ("apply", "--org", "eclipse-score", "--allow-dirty-pr") ) assert args.allow_dirty_pr is True @@ -429,6 +466,7 @@ def test_dirty_pull_requests_can_be_enabled() -> None: def test_policy_directory_can_be_repeated() -> None: args = cli.create_parser().parse_args( ( + "plan", "--org", "eclipse-score", "--policy-dir", @@ -444,7 +482,7 @@ def test_policy_directory_can_be_repeated() -> None: def test_removed_policy_directory_alias_is_rejected() -> None: with pytest.raises(SystemExit) as exit_code: cli.create_parser().parse_args( - ("--org", "eclipse-score", "--policy-directory", "policies") + ("plan", "--org", "eclipse-score", "--policy-directory", "policies") ) assert exit_code.value.code == 2 @@ -452,7 +490,13 @@ def test_removed_policy_directory_alias_is_rejected() -> None: def test_bundled_policies_can_be_excluded_separately() -> None: args = cli.create_parser().parse_args( - ("--org", "eclipse-score", "--exclude-bundled-policy", "minimum-bazel-version") + ( + "plan", + "--org", + "eclipse-score", + "--exclude-bundled-policy", + "minimum-bazel-version", + ) ) assert args.exclude_bundled_policy == ["minimum-bazel-version"] @@ -462,6 +506,15 @@ 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 ( @@ -469,8 +522,6 @@ def test_help_groups_options_by_frequency(capsys) -> None: < help_text.index("Rare:") < help_text.index("Debugging only:") ) - assert ( - help_text.index("--apply") - < help_text.index("--recreate") - < help_text.index("--cache-dir") - ) + _, 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 index 41fd57d..b44353c 100644 --- a/repo_policy_sync/tests/test_config.py +++ b/repo_policy_sync/tests/test_config.py @@ -28,7 +28,6 @@ def test_load_config_resolves_policy_directories_relative_to_config( org = "eclipse-score" policies = ["minimum-bazel-version"] repos = ["reference_integration"] -apply = true policy_dirs = ["policies", "shared"] exclude_bundled_policies = ["minimum-bazel-version"] recreate = true @@ -46,7 +45,6 @@ def test_load_config_resolves_policy_directories_relative_to_config( assert config.org == "eclipse-score" assert config.policies == ("minimum-bazel-version",) assert config.repositories == ("reference_integration",) - assert config.apply is True assert config.policy_directories == (tmp_path / "policies", tmp_path / "shared") assert config.exclude_bundled_policies == ("minimum-bazel-version",) assert config.recreate is True diff --git a/repo_policy_sync/tests/test_engine.py b/repo_policy_sync/tests/test_engine.py index 255957f..721179e 100644 --- a/repo_policy_sync/tests/test_engine.py +++ b/repo_policy_sync/tests/test_engine.py @@ -674,7 +674,7 @@ def test_synchronize_file_replaces_contents_and_makes_the_target_executable( assert apply_policy(tmp_path, policy).changes == () -def test_synchronize_workflow_inserts_missing_name_and_preserves_jobs( +def test_synchronize_file_inserts_missing_name_and_preserves_workflow_jobs( tmp_path: Path, ) -> None: target = tmp_path / ".github/workflows/docs.yml" @@ -723,7 +723,56 @@ def test_synchronize_workflow_inserts_missing_name_and_preserves_jobs( assert " docs:\n" in result -def test_synchronize_workflow_keeps_sections_separated_without_source_newline( +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" @@ -756,7 +805,7 @@ def test_synchronize_workflow_keeps_sections_separated_without_source_newline( ) -def test_synchronize_workflow_rejects_job_id_collision(tmp_path: Path) -> None: +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( @@ -792,7 +841,7 @@ def test_synchronize_workflow_rejects_job_id_collision(tmp_path: Path) -> None: apply_policy(tmp_path, policy) -def test_synchronize_workflow_rejects_four_space_job_id_collision( +def test_synchronize_file_rejects_four_space_workflow_job_id_collision( tmp_path: Path, ) -> None: target = tmp_path / ".github/workflows/docs.yml" @@ -830,7 +879,7 @@ def test_synchronize_workflow_rejects_four_space_job_id_collision( apply_policy(tmp_path, policy) -def test_synchronize_workflow_preserves_publish_permissions_on_matching_job( +def test_synchronize_file_preserves_publish_permissions_on_matching_job( tmp_path: Path, ) -> None: target = tmp_path / ".github/workflows/docs-publish.yml" 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,)))