diff --git a/.gitignore b/.gitignore index 132f11e..aa7d8dd 100644 --- a/.gitignore +++ b/.gitignore @@ -161,10 +161,12 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# quarto /.quarto/ /_site/ /objects.json /reference /_sidebar.yml - **/*.quarto_ipynb + +/.posit/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c5750..488f64b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## actions development version +- New commands `list-rulesets` and `copy-ruleset`, plus a `copy-ruleset` action, for copying GitHub rulesets between repositories. (#183, @kelly-sovacool, @copilot) + ## actions 0.7.1 - Fix `draft-release` to preserve custom keys in `CITATION.cff` in R packages. (#180, @kelly-sovacool) diff --git a/README.md b/README.md index 48e6281..3d7f43b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ them for your needs. - [build-snakemake](examples/build-snakemake.yml) - [changed-files](examples/changed-files.yml) - [check-links](examples/check-links.yml) +- [copy-ruleset](examples/copy-ruleset.yml) - [docs-mkdocs](examples/docs-mkdocs.yml) - [docs-quarto](examples/docs-quarto.yml) - [draft-release](examples/draft-release.yml) @@ -54,6 +55,8 @@ Custom actions used in our github workflows. guidelines - [Changed Files](changed-files) - Get a list of changed files and filter them by a list of path patterns similar to .gitignore +- [Copy Ruleset](copy-ruleset) - Copy a repository ruleset from one + GitHub repository to another. - [draft-release](draft-release) - Draft a new release based on conventional commits and prepare release notes - [Install R + pak](install-r-pak) - Install R package dependencies with @@ -115,7 +118,9 @@ pip install git+https://github.com/CCBR/actions@v0.7 -h, --help Show this message and exit. Commands: - use-example Use a GitHub Actions workflow file from CCBR/actions. + use-example Use a GitHub Actions workflow file from CCBR/actions. + list-rulesets List all rulesets for a GitHub repository. + copy-ruleset Copy a ruleset from one GitHub repository to another. #### use-example @@ -136,6 +141,45 @@ pip install git+https://github.com/CCBR/actions@v0.7 Options: -h, --help Show this message and exit. +#### list-rulesets + + Usage: ccbr_actions list-rulesets [OPTIONS] REPO + + List all rulesets for a GitHub repository. + + Args: + repo (str): Repository in owner/repo format. + + Examples: + ccbr_actions list-rulesets CCBR/actions + ccbr_actions list-rulesets CCBR/actions --token ghp_... + + Options: + -t, --token TEXT GitHub token with repo scope. Defaults to the GH_TOKEN + environment variable. + -h, --help Show this message and exit. + +#### copy-ruleset + + Usage: ccbr_actions copy-ruleset [OPTIONS] SOURCE_REPO TARGET_REPO + RULESET_NAME + + Copy a ruleset from one GitHub repository to another. + + Args: + source-repo (str): Source repository in owner/repo format. + target-repo (str): Target repository in owner/repo format. + ruleset-name (str): Name of the ruleset to copy. + + Examples: + ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" + ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" --token ghp_... + + Options: + -t, --token TEXT GitHub token with repo scope. Defaults to the GH_TOKEN + environment variable. + -h, --help Show this message and exit. + ## Help & Contributing Come across a **bug**? Open an diff --git a/copy-ruleset/README.md b/copy-ruleset/README.md new file mode 100644 index 0000000..f2c7b4d --- /dev/null +++ b/copy-ruleset/README.md @@ -0,0 +1,124 @@ +# copy-ruleset + +**`Copy Ruleset`** - Copy a repository ruleset from one GitHub +repository to another. + +Copy a named ruleset from one GitHub repository to another. This action +fetches the full ruleset definition from the source repository via the +GitHub API and creates an identical ruleset in the target repository. It +is useful for standardising branch protection rules across multiple +repositories in an organisation. + +## Usage + +### Basic example + +[copy-ruleset.yml](/examples/copy-ruleset.yml) + +```yaml +name: copy-ruleset + +on: + workflow_dispatch: + inputs: + source-repo: + description: "Source repository (owner/repo) to copy the ruleset from." + required: true + target-repo: + description: "Target repository (owner/repo) to copy the ruleset to." + required: true + ruleset-name: + description: "Name of the ruleset to copy." + required: true + # Push to the default branch can handle first runs in repositories + # created from templates (initial commit push). + push: + branches: + - main + - master + # Allow central automation to trigger this workflow remotely. + repository_dispatch: + types: + - copy-ruleset + # Optional periodic sync to keep rulesets aligned over time. + schedule: + - cron: "0 5 * * 1" + +permissions: + contents: read + +jobs: + copy-ruleset: + if: >- + ${{ + github.event_name == 'workflow_dispatch' || + (vars.RULESET_SOURCE_REPO != '' && vars.RULESET_NAME != '') + }} + runs-on: ubuntu-latest + steps: + - uses: CCBR/actions/copy-ruleset@latest + with: + source-repo: ${{ github.event_name == 'workflow_dispatch' && inputs['source-repo'] || vars.RULESET_SOURCE_REPO }} + target-repo: ${{ github.event_name == 'workflow_dispatch' && inputs['target-repo'] || vars.RULESET_TARGET_REPO || github.repository }} + ruleset-name: ${{ github.event_name == 'workflow_dispatch' && inputs['ruleset-name'] || vars.RULESET_NAME }} + token: ${{ secrets.ORG_PAT }} +``` + +### Using a PAT for cross-repository access + +`github.token` is scoped to the repository running the workflow. To copy +rulesets to a repository outside the current one, supply a personal +access token (PAT) or a GitHub App token with `repo` scope on both +repositories: + +```yaml +steps: + - uses: CCBR/actions/copy-ruleset@main + with: + source-repo: CCBR/actions + target-repo: CCBR/other-repo + ruleset-name: "Require PR reviews" + token: ${{ secrets.ORG_PAT }} +``` + +## Running locally + +Both operations can also be performed directly in the terminal using the +[`gh` CLI](https://cli.github.com/) and `jq`, without installing any +additional packages. + +**List rulesets** in a repository: + +```bash +gh api repos/{owner}/{repo}/rulesets --jq '.[] | [.id, .enforcement, .name] | @tsv' +``` + +**Copy a ruleset** from one repository to another: + +```bash +gh api repos/{source_owner}/{source_repo}/rulesets \ + --jq '.[] | select(.name == "Require PR reviews") | .id' \ + | xargs -I{} gh api repos/{source_owner}/{source_repo}/rulesets/{} \ + | jq 'del(.id, .node_id, .created_at, .updated_at, ._links)' \ + | gh api repos/{target_owner}/{target_repo}/rulesets \ + --method POST \ + --input - +``` + +Alternatively, using the `ccbr_actions` Python package: + +```bash +# list rulesets +ccbr_actions list-rulesets owner/repo + +# copy a ruleset +ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" +``` + +## Inputs + +- `source-repo`: Source repository in owner/repo format. **Required.** +- `target-repo`: Target repository in owner/repo format. **Required.** +- `ruleset-name`: Name of the ruleset to copy. **Required.** +- `token`: GitHub token with repo scope on both repositories. Default: + `${{ github.token }}`. diff --git a/copy-ruleset/README.qmd b/copy-ruleset/README.qmd new file mode 100644 index 0000000..97867f9 --- /dev/null +++ b/copy-ruleset/README.qmd @@ -0,0 +1,83 @@ +--- +title: copy-ruleset +format: gfm +execute: + echo: false + output: asis +--- + +```{python} +import ccbr_actions.docs + +action = ccbr_actions.docs.parse_action_yaml('action.yml') +print(ccbr_actions.docs.action_markdown_desc(action)) +``` + +Copy a named ruleset from one GitHub repository to another. +This action fetches the full ruleset definition from the source repository via the GitHub API and creates an identical ruleset in the target repository. +It is useful for standardising branch protection rules across multiple repositories in an organisation. + +## Usage + +### Basic example + +[copy-ruleset.yml](/examples/copy-ruleset.yml) + +```{python} +yml_file = '../examples/copy-ruleset.yml' +print('```yaml') +with open(yml_file, 'r') as infile: + print(infile.read()) +print('```\n') +``` + +### Using a PAT for cross-repository access + +`github.token` is scoped to the repository running the workflow. +To copy rulesets to a repository outside the current one, supply a personal access token (PAT) or a GitHub App token with `repo` scope on both repositories: + +```yaml +steps: + - uses: CCBR/actions/copy-ruleset@main + with: + source-repo: CCBR/actions + target-repo: CCBR/other-repo + ruleset-name: "Require PR reviews" + token: ${{ secrets.ORG_PAT }} +``` + +## Running locally + +Both operations can also be performed directly in the terminal using the [`gh` CLI](https://cli.github.com/) and `jq`, without installing any additional packages. + +**List rulesets** in a repository: + +```bash +gh api repos/{owner}/{repo}/rulesets --jq '.[] | [.id, .enforcement, .name] | @tsv' +``` + +**Copy a ruleset** from one repository to another: + +```bash +gh api repos/{source_owner}/{source_repo}/rulesets \ + --jq '.[] | select(.name == "Require PR reviews") | .id' \ + | xargs -I{} gh api repos/{source_owner}/{source_repo}/rulesets/{} \ + | jq 'del(.id, .node_id, .created_at, .updated_at, ._links)' \ + | gh api repos/{target_owner}/{target_repo}/rulesets \ + --method POST \ + --input - +``` + +Alternatively, using the `ccbr_actions` Python package: + +```bash +# list rulesets +ccbr_actions list-rulesets owner/repo + +# copy a ruleset +ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" +``` + +```{python} +print(ccbr_actions.docs.action_markdown_io(action)) +``` diff --git a/copy-ruleset/action.yml b/copy-ruleset/action.yml new file mode 100644 index 0000000..1a2e900 --- /dev/null +++ b/copy-ruleset/action.yml @@ -0,0 +1,55 @@ +name: "Copy Ruleset" +author: "Kelly Sovacool" + +description: | + Copy a repository ruleset from one GitHub repository to another. + +branding: + icon: copy + color: purple + +inputs: + source-repo: + description: "Source repository in owner/repo format." + required: true + target-repo: + description: "Target repository in owner/repo format." + required: true + ruleset-name: + description: "Name of the ruleset to copy." + required: true + token: + description: "GitHub token with repo scope on both repositories." + required: false + default: "${{ github.token }}" + +runs: + using: composite + steps: + - name: Copy ruleset + shell: bash + env: + GH_TOKEN: "${{ inputs.token }}" + run: | + ruleset_id=$( + gh api "repos/${{ inputs.source-repo }}/rulesets" \ + --jq --arg name "${{ inputs.ruleset-name }}" \ + '.[] | select(.name == $name) | .id' + ) + + if [ -z "$ruleset_id" ]; then + echo "::error::Ruleset '${{ inputs.ruleset-name }}' not found in ${{ inputs.source-repo }}." + echo "Available rulesets:" + gh api "repos/${{ inputs.source-repo }}/rulesets" --jq '.[].name' + exit 1 + fi + + result=$( + gh api "repos/${{ inputs.source-repo }}/rulesets/${ruleset_id}" \ + | jq 'del(.id, .node_id, .created_at, .updated_at, ._links)' \ + | gh api "repos/${{ inputs.target-repo }}/rulesets" \ + --method POST \ + --input - + ) + + echo "Ruleset '$(echo "$result" | jq -r '.name')' (id=$(echo "$result" | jq -r '.id')) created in ${{ inputs.target-repo }}." diff --git a/examples/copy-ruleset.yml b/examples/copy-ruleset.yml new file mode 100644 index 0000000..81cb1f7 --- /dev/null +++ b/examples/copy-ruleset.yml @@ -0,0 +1,46 @@ +name: copy-ruleset + +on: + workflow_dispatch: + inputs: + source-repo: + description: "Source repository (owner/repo) to copy the ruleset from." + required: true + target-repo: + description: "Target repository (owner/repo) to copy the ruleset to." + required: true + ruleset-name: + description: "Name of the ruleset to copy." + required: true + # Push to the default branch can handle first runs in repositories + # created from templates (initial commit push). + push: + branches: + - main + - master + # Allow central automation to trigger this workflow remotely. + repository_dispatch: + types: + - copy-ruleset + # Optional periodic sync to keep rulesets aligned over time. + schedule: + - cron: "0 5 * * 1" + +permissions: + contents: read + +jobs: + copy-ruleset: + if: >- + ${{ + github.event_name == 'workflow_dispatch' || + (vars.RULESET_SOURCE_REPO != '' && vars.RULESET_NAME != '') + }} + runs-on: ubuntu-latest + steps: + - uses: CCBR/actions/copy-ruleset@latest + with: + source-repo: ${{ github.event_name == 'workflow_dispatch' && inputs['source-repo'] || vars.RULESET_SOURCE_REPO }} + target-repo: ${{ github.event_name == 'workflow_dispatch' && inputs['target-repo'] || vars.RULESET_TARGET_REPO || github.repository }} + ruleset-name: ${{ github.event_name == 'workflow_dispatch' && inputs['ruleset-name'] || vars.RULESET_NAME }} + token: ${{ secrets.ORG_PAT }} diff --git a/src/ccbr_actions/__main__.py b/src/ccbr_actions/__main__.py index feb7434..3519cdb 100644 --- a/src/ccbr_actions/__main__.py +++ b/src/ccbr_actions/__main__.py @@ -8,6 +8,7 @@ from .util import repo_base, print_citation from .actions import use_github_action +from .github import copy_ruleset, list_rulesets @click.group( @@ -59,12 +60,83 @@ def use_example(name): cli.add_command(use_example) +@click.command() +@click.argument("repo") +@click.option( + "--token", + "-t", + envvar="GH_TOKEN", + default=None, + help="GitHub token with repo scope. Defaults to the GH_TOKEN environment variable.", +) +def list_rulesets_cmd(repo, token): + """ + List all rulesets for a GitHub repository. + + \b + Args: + repo (str): Repository in owner/repo format. + + \b + Examples: + ccbr_actions list-rulesets CCBR/actions + ccbr_actions list-rulesets CCBR/actions --token ghp_... + """ + rulesets = list_rulesets(repo=repo, token=token) + if not rulesets: + click.echo(f"No rulesets found in {repo}.") + else: + for r in rulesets: + click.echo(f"{r['id']:>10} {r['enforcement']:<12} {r['name']}") + + +cli.add_command(list_rulesets_cmd, name="list-rulesets") + + +@click.command() +@click.argument("source-repo") +@click.argument("target-repo") +@click.argument("ruleset-name") +@click.option( + "--token", + "-t", + envvar="GH_TOKEN", + default=None, + help="GitHub token with repo scope. Defaults to the GH_TOKEN environment variable.", +) +def copy_ruleset_cmd(source_repo, target_repo, ruleset_name, token): + """ + Copy a ruleset from one GitHub repository to another. + + \b + Args: + source-repo (str): Source repository in owner/repo format. + target-repo (str): Target repository in owner/repo format. + ruleset-name (str): Name of the ruleset to copy. + + \b + Examples: + ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" + ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" --token ghp_... + """ + result = copy_ruleset( + source_repo=source_repo, + target_repo=target_repo, + ruleset_name=ruleset_name, + token=token, + ) + click.echo( + f"Ruleset '{result['name']}' (id={result['id']}) created in {target_repo}." + ) + + +cli.add_command(copy_ruleset_cmd, name="copy-ruleset") + + def main(): """Run the Click CLI entry point.""" cli() -cli(prog_name="ccbr_actions") - if __name__ == "__main__": - main() + cli(prog_name="ccbr_actions") diff --git a/src/ccbr_actions/github.py b/src/ccbr_actions/github.py index 0a041dc..f90cc39 100644 --- a/src/ccbr_actions/github.py +++ b/src/ccbr_actions/github.py @@ -94,3 +94,91 @@ def github_api_post(url, token=None, session=requests, **kwargs): return github_api_request( method="POST", url=url, token=token, session=session, **kwargs ) + + +def list_rulesets(repo, token=None, session=requests): + """ + List all rulesets for a GitHub repository. + + Args: + repo (str): Repository in ``owner/repo`` format. + token (str, optional): GitHub token with ``repo`` scope. + session: Object with a requests-compatible ``request`` method, or a + method-only interface providing ``get``. + Returns: + list[dict]: Rulesets as returned by the GitHub API. Each item + contains at minimum ``id``, ``name``, and ``enforcement``. + + Raises: + requests.HTTPError: If the GitHub API request fails. + + Examples: + >>> list_rulesets("CCBR/actions", token="ghp_...") + """ + url = f"{GITHUB_API_URL}/repos/{repo}/rulesets" + return github_api_get(url, token=token, session=session) + + +def copy_ruleset( + source_repo, + target_repo, + ruleset_name, + token=None, + session=requests, +): + """ + Copy a ruleset from one GitHub repository to another. + + Fetches all rulesets from ``source_repo``, finds the one matching + ``ruleset_name``, and creates an identical ruleset in ``target_repo``. + + Args: + source_repo (str): Source repository in ``owner/repo`` format. + target_repo (str): Target repository in ``owner/repo`` format. + ruleset_name (str): Name of the ruleset to copy. + token (str, optional): GitHub token with ``repo`` scope. + session: Object with a requests-compatible ``request`` method, or a + method-only interface providing ``get``/``post``. + Returns: + dict: The created ruleset as returned by the GitHub API. + + Raises: + ValueError: If no ruleset with ``ruleset_name`` is found in + ``source_repo``. + requests.HTTPError: If any GitHub API request fails. + + Examples: + >>> copy_ruleset( + ... source_repo="CCBR/actions", + ... target_repo="CCBR/other-repo", + ... ruleset_name="Require PR reviews", + ... token="ghp_...", + ... ) + """ + # Fetch all rulesets from the source repository + list_url = f"{GITHUB_API_URL}/repos/{source_repo}/rulesets" + rulesets = github_api_get(list_url, token=token, session=session) + + # Find the matching ruleset by name + match = next((r for r in rulesets if r.get("name") == ruleset_name), None) + if match is None: + available = [r.get("name") for r in rulesets] + raise ValueError( + f"Ruleset {ruleset_name!r} not found in {source_repo!r}. " + f"Available rulesets: {available}" + ) + + # Fetch the full ruleset definition (the list endpoint omits some fields) + ruleset_id = match["id"] + detail_url = f"{GITHUB_API_URL}/repos/{source_repo}/rulesets/{ruleset_id}" + ruleset = github_api_get(detail_url, token=token, session=session) + + # Build the payload for the target repository, dropping read-only fields + _read_only_fields = {"id", "node_id", "created_at", "updated_at", "_links"} + payload = {k: v for k, v in ruleset.items() if k not in _read_only_fields} + + # Create the ruleset in the target repository + create_url = f"{GITHUB_API_URL}/repos/{target_repo}/rulesets" + response = github_api_post(create_url, token=token, session=session, json=payload) + response.raise_for_status() + return response.json() diff --git a/tests/test_cli.py b/tests/test_cli.py index 04f487b..ad398c4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,9 @@ import os import pathlib +from unittest.mock import patch +from click.testing import CliRunner from ccbr_tools.shell import shell_run +from ccbr_actions.__main__ import cli def test_citation(): @@ -20,3 +23,132 @@ def test_use_example(tmp_path): finally: os.chdir(current_wd) assert outfile.exists() + + +# --------------------------------------------------------------------------- +# list-rulesets CLI +# --------------------------------------------------------------------------- + +MOCK_RULESETS = [ + {"id": 1, "name": "Require PR reviews", "enforcement": "active"}, + {"id": 2, "name": "Restrict force push", "enforcement": "evaluate"}, +] + + +def test_list_rulesets_prints_rulesets(): + runner = CliRunner() + with patch("ccbr_actions.__main__.list_rulesets", return_value=MOCK_RULESETS): + result = runner.invoke(cli, ["list-rulesets", "CCBR/actions"]) + + assert result.exit_code == 0 + assert "Require PR reviews" in result.output + assert "Restrict force push" in result.output + assert "active" in result.output + assert "evaluate" in result.output + + +def test_list_rulesets_prints_message_when_empty(): + runner = CliRunner() + with patch("ccbr_actions.__main__.list_rulesets", return_value=[]): + result = runner.invoke(cli, ["list-rulesets", "CCBR/empty-repo"]) + + assert result.exit_code == 0 + assert "No rulesets found" in result.output + + +def test_list_rulesets_passes_token(): + runner = CliRunner() + with patch("ccbr_actions.__main__.list_rulesets", return_value=[]) as mock_fn: + runner.invoke(cli, ["list-rulesets", "CCBR/actions", "--token", "ghp_test"]) + + mock_fn.assert_called_once_with(repo="CCBR/actions", token="ghp_test") + + +def test_list_rulesets_reads_token_from_env(): + runner = CliRunner() + with patch("ccbr_actions.__main__.list_rulesets", return_value=[]) as mock_fn: + result = runner.invoke( + cli, ["list-rulesets", "CCBR/actions"], env={"GH_TOKEN": "ghp_env"} + ) + + assert result.exit_code == 0 + mock_fn.assert_called_once_with(repo="CCBR/actions", token="ghp_env") + + +# --------------------------------------------------------------------------- +# copy-ruleset CLI +# --------------------------------------------------------------------------- + +CREATED_RULESET = {"id": 99, "name": "Require PR reviews"} + + +def test_copy_ruleset_prints_confirmation(): + runner = CliRunner() + with patch("ccbr_actions.__main__.copy_ruleset", return_value=CREATED_RULESET): + result = runner.invoke( + cli, + ["copy-ruleset", "CCBR/actions", "CCBR/other-repo", "Require PR reviews"], + ) + + assert result.exit_code == 0 + assert "Require PR reviews" in result.output + assert "99" in result.output + assert "CCBR/other-repo" in result.output + + +def test_copy_ruleset_passes_arguments(): + runner = CliRunner() + with patch( + "ccbr_actions.__main__.copy_ruleset", return_value=CREATED_RULESET + ) as mock_fn: + runner.invoke( + cli, + [ + "copy-ruleset", + "CCBR/actions", + "CCBR/other-repo", + "Require PR reviews", + "--token", + "ghp_test", + ], + ) + + mock_fn.assert_called_once_with( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + token="ghp_test", + ) + + +def test_copy_ruleset_reads_token_from_env(): + runner = CliRunner() + with patch( + "ccbr_actions.__main__.copy_ruleset", return_value=CREATED_RULESET + ) as mock_fn: + runner.invoke( + cli, + ["copy-ruleset", "CCBR/actions", "CCBR/other-repo", "Require PR reviews"], + env={"GH_TOKEN": "ghp_env"}, + ) + + mock_fn.assert_called_once_with( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + token="ghp_env", + ) + + +def test_copy_ruleset_surfaces_value_error(): + runner = CliRunner() + with patch( + "ccbr_actions.__main__.copy_ruleset", + side_effect=ValueError("'Nonexistent' not found"), + ): + result = runner.invoke( + cli, + ["copy-ruleset", "CCBR/actions", "CCBR/other-repo", "Nonexistent"], + ) + + assert result.exit_code != 0 diff --git a/tests/test_github.py b/tests/test_github.py index 5c79978..4339546 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -1,8 +1,11 @@ +import pytest from ccbr_actions.github import ( github_api_get, github_api_headers, github_api_post, github_api_request, + list_rulesets, + copy_ruleset, ) @@ -85,3 +88,184 @@ def test_github_api_post_supports_method_only_interface(): assert response.status_code == 201 assert session.calls[0][0] == "POST" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +RULESET_SUMMARY = [ + {"id": 1, "name": "Require PR reviews", "enforcement": "active"}, + {"id": 2, "name": "Restrict force push", "enforcement": "evaluate"}, +] + +RULESET_DETAIL = { + "id": 1, + "node_id": "abc123", + "name": "Require PR reviews", + "enforcement": "active", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "_links": { + "self": {"href": "https://api.github.com/repos/CCBR/actions/rulesets/1"} + }, + "rules": [{"type": "pull_request"}], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, +} + +CREATED_RULESET = { + "id": 99, + "name": "Require PR reviews", + "enforcement": "active", + "rules": [{"type": "pull_request"}], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, +} + + +class RulesetMockSession: + """Mock session that serves realistic ruleset API responses.""" + + def __init__(self): + self.calls = [] + + def get(self, url, headers=None, **kwargs): + self.calls.append(("GET", url)) + if url.endswith("/rulesets"): + return MockResponse(RULESET_SUMMARY) + if "/rulesets/1" in url: # abs-path:ignore + return MockResponse(RULESET_DETAIL) + return MockResponse([], status_code=404) + + def post(self, url, headers=None, **kwargs): + self.calls.append(("POST", url, kwargs.get("json"))) + return MockResponse(CREATED_RULESET, status_code=201) + + +# --------------------------------------------------------------------------- +# list_rulesets +# --------------------------------------------------------------------------- + + +def test_list_rulesets_returns_list(): + session = RulesetMockSession() + + result = list_rulesets(repo="CCBR/actions", session=session) + + assert result == RULESET_SUMMARY + + +def test_list_rulesets_calls_correct_url(): + session = RulesetMockSession() + + list_rulesets(repo="CCBR/actions", session=session) + + method, url = session.calls[0] + assert method == "GET" + assert url == "https://api.github.com/repos/CCBR/actions/rulesets" + + +def test_list_rulesets_returns_empty_list_when_none(): + class EmptySession: + def get(self, url, headers=None, **kwargs): + return MockResponse([]) + + result = list_rulesets(repo="CCBR/empty-repo", session=EmptySession()) + + assert result == [] + + +# --------------------------------------------------------------------------- +# copy_ruleset +# --------------------------------------------------------------------------- + + +def test_copy_ruleset_returns_created_ruleset(): + session = RulesetMockSession() + + result = copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + session=session, + ) + + assert result == CREATED_RULESET + + +def test_copy_ruleset_strips_read_only_fields(): + session = RulesetMockSession() + + copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + session=session, + ) + + # Third call is POST; inspect the payload sent + _, _url, payload = session.calls[2] + for field in ("id", "node_id", "created_at", "updated_at", "_links"): + assert field not in payload, f"Read-only field '{field}' should be stripped" + + +def test_copy_ruleset_preserves_rules_and_conditions(): + session = RulesetMockSession() + + copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + session=session, + ) + + _, _url, payload = session.calls[2] + assert payload["rules"] == RULESET_DETAIL["rules"] + assert payload["conditions"] == RULESET_DETAIL["conditions"] + + +def test_copy_ruleset_calls_correct_urls(): + session = RulesetMockSession() + + copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + session=session, + ) + + assert session.calls[0] == ( + "GET", + "https://api.github.com/repos/CCBR/actions/rulesets", + ) + assert session.calls[1] == ( + "GET", + "https://api.github.com/repos/CCBR/actions/rulesets/1", + ) + assert session.calls[2][0] == "POST" + assert ( + session.calls[2][1] == "https://api.github.com/repos/CCBR/other-repo/rulesets" + ) + + +def test_copy_ruleset_raises_when_ruleset_not_found(): + session = RulesetMockSession() + + with pytest.raises(ValueError, match="'Nonexistent'"): + copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Nonexistent", + session=session, + ) + + +def test_copy_ruleset_error_includes_available_names(): + session = RulesetMockSession() + + with pytest.raises(ValueError, match="Require PR reviews"): + copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Nonexistent", + session=session, + )