Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 129 additions & 1 deletion .github/cookiecutter-migrate.template.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@
And remember to follow any manual instructions for each run.
""" # noqa: E501

# pylint: disable=too-many-lines

import hashlib
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import SupportsIndex
from typing import Any, SupportsIndex

_manual_steps: list[str] = [] # pylint: disable=invalid-name

Expand Down Expand Up @@ -179,6 +181,132 @@ def calculate_file_sha256_skip_lines(filepath: Path, skip_lines: int) -> str | N
return hashlib.sha256(remaining_content.encode()).hexdigest()


def find_ruleset(name: str) -> dict[str, Any] | None:
"""Find a repository ruleset by name using the GitHub API.

Args:
name: The name of the ruleset to search for.

Returns:
The ruleset summary dict (id, name, …) if found, or ``None`` if not
found or if the API call failed (a diagnostic is printed in the latter
case).
"""
try:
stdout = subprocess.check_output(
["gh", "api", "repos/:owner/:repo/rulesets"],
text=True,
stderr=subprocess.PIPE,
)
except FileNotFoundError:
print(" gh CLI not found; cannot query rulesets via the GitHub API.")
return None
except subprocess.CalledProcessError as exc:
print(f" Failed to list rulesets: {exc.stderr.strip()}")
return None

rulesets: list[dict[str, Any]] = json.loads(stdout)
return next((r for r in rulesets if r.get("name") == name), None)


def get_ruleset(ruleset: str | int) -> dict[str, Any] | None:
"""Fetch the full details of a repository ruleset by name or ID.

Args:
ruleset: The ruleset name (``str``) or numeric ruleset ID (``int``).

Returns:
The full ruleset dict, or ``None`` if the ruleset could not be found
or the API call failed (a diagnostic is printed).
"""
ruleset_id = ruleset
if isinstance(ruleset, str):
entry = find_ruleset(ruleset)
if entry is None:
return None
ruleset_id = entry["id"]

try:
stdout = subprocess.check_output(
["gh", "api", f"repos/:owner/:repo/rulesets/{ruleset_id}"],
text=True,
stderr=subprocess.PIPE,
)
except subprocess.CalledProcessError as exc:
print(f" Failed to fetch ruleset {ruleset_id}: {exc.stderr.strip()}")
return None

return json.loads(stdout) # type: ignore[no-any-return]


def update_ruleset(ruleset_id: int, config: dict[str, Any]) -> bool:
"""Update a repository ruleset via the GitHub API.

Only ``name``, ``target``, ``enforcement``, ``conditions``, ``rules``,
and ``bypass_actors`` are sent (explicit allowlist to avoid sending
read-only fields back to the API).

Args:
ruleset_id: The numeric ruleset ID to update.
config: The full ruleset dict (as returned by :func:`get_ruleset`)
with the desired changes already applied in-memory.

Returns:
``True`` on success, ``False`` if the API call failed (a diagnostic
is printed).
"""
payload: dict[str, Any] = {
"name": config["name"],
"target": config["target"],
"enforcement": config["enforcement"],
"conditions": config["conditions"],
"rules": config["rules"],
}
if "bypass_actors" in config:
payload["bypass_actors"] = config["bypass_actors"]

try:
subprocess.check_output(
[
"gh",
"api",
"-X",
"PUT",
f"repos/:owner/:repo/rulesets/{ruleset_id}",
"--input",
"-",
],
input=json.dumps(payload),
text=True,
stderr=subprocess.PIPE,
)
except subprocess.CalledProcessError as exc:
print(f" Failed to update ruleset {ruleset_id}: {exc.stderr.strip()}")
return False

return True


def get_ruleset_settings_url() -> str | None:
"""Return the URL to the repository's ruleset settings page.

Returns:
The URL as a string, or ``None`` if it could not be determined.
"""
try:
stdout = subprocess.check_output(
["gh", "repo", "view", "--json", "owner,name"],
text=True,
stderr=subprocess.PIPE,
)
info: dict[str, Any] = json.loads(stdout)
org = info["owner"]["login"]
repo = info["name"]
return f"https://github.com/{org}/{repo}/settings/rules"
except (subprocess.CalledProcessError, KeyError, json.JSONDecodeError):
return None


def manual_step(message: str) -> None:
"""Print a manual step message in yellow."""
_manual_steps.append(message)
Expand Down
2 changes: 2 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ But you might still need to adapt your code:

- The `auto-dependabot.yaml` workflow now skips repo-config group PRs, which are handled by the new migration workflow instead.

- Updated the default "Protect version branches" ruleset to require the new `Migrate Repo Config` status check, which is added by the migration workflow to PRs that need manual intervention. This prevents merging PRs that require manual migration steps until those steps are completed and the check passes. Also removed the required code-owner approval and automatic Copilot review request.

## Bug Fixes

<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
Expand Down
Loading