Skip to content

Commit 8c903d6

Browse files
committed
migrate: Update Protect version branches ruleset
Add a migration step that updates the "Protect version branches" GitHub ruleset from migrate.py. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
1 parent cc465a6 commit 8c903d6

1 file changed

Lines changed: 215 additions & 1 deletion

File tree

cookiecutter/migrate.py

Lines changed: 215 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import sys
3131
import tempfile
3232
from pathlib import Path
33-
from typing import SupportsIndex
33+
from typing import Any, SupportsIndex
3434

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

@@ -60,6 +60,9 @@ def main() -> None:
6060
print("Installing repo-config migration workflow...")
6161
migrate_repo_config_workflow()
6262
print("=" * 72)
63+
print("Updating 'Protect version branches' GitHub ruleset...")
64+
migrate_protect_version_branches_ruleset()
65+
print("=" * 72)
6366
print()
6467

6568
if _manual_steps:
@@ -649,6 +652,217 @@ def migrate_repo_config_workflow() -> None:
649652
)
650653

651654

655+
def migrate_protect_version_branches_ruleset() -> None:
656+
"""Update the 'Protect version branches' GitHub ruleset.
657+
658+
Uses the GitHub API (via ``gh`` CLI) to check whether the
659+
'Protect version branches' ruleset on the current repository is aligned
660+
with the current template. Recent template changes include:
661+
662+
* Setting ``require_code_owner_review`` to ``false``.
663+
* Adding an (empty) ``required_reviewers`` list.
664+
* Removing the ``automatic_copilot_code_review_enabled`` setting.
665+
* Adding ``Migrate Repo Config`` to the required status checks.
666+
* Setting the ``OrganizationAdmin`` bypass-actor ``actor_id`` to
667+
``null``.
668+
669+
If the ruleset is already aligned, prints an informational message.
670+
If it needs updating, applies the changes via the API without removing
671+
any existing required status checks.
672+
If the ruleset is not found at all, issues a manual-step message that
673+
points the user to the docs.
674+
"""
675+
rule_name = "Protect version branches"
676+
docs_url = (
677+
"https://frequenz-floss.github.io/frequenz-repo-config-python/"
678+
"user-guide/start-a-new-project/configure-github/#rulesets"
679+
)
680+
681+
# Build a link to the repo's ruleset settings for manual-step messages.
682+
ruleset_url = get_ruleset_settings_url() or docs_url
683+
684+
# ── Fetch ruleset details ────────────────────────────────────────
685+
ruleset = get_ruleset(rule_name)
686+
if ruleset is None:
687+
manual_step(
688+
f"The '{rule_name}' GitHub ruleset was not found (or the gh CLI "
689+
"is not available / the API call failed). "
690+
"Please check whether it should exist for this repository. "
691+
f"If it should, import it following the instructions at: {docs_url}"
692+
)
693+
return
694+
695+
ruleset_id = ruleset.get("id")
696+
if not isinstance(ruleset_id, int):
697+
manual_step(
698+
f"Failed to determine the '{rule_name}' ruleset ID from the "
699+
f"GitHub API response. Please update it manually at: {ruleset_url}"
700+
)
701+
return
702+
703+
# ── Detect and apply changes in-memory ───────────────────────────────
704+
changes: list[str] = []
705+
706+
for rule in ruleset.get("rules", []):
707+
if rule.get("type") == "pull_request":
708+
params = rule.setdefault("parameters", {})
709+
if params.get("require_code_owner_review") is True:
710+
params["require_code_owner_review"] = False
711+
changes.append("set require_code_owner_review=false")
712+
if params.pop("automatic_copilot_code_review_enabled", None) is not None:
713+
changes.append("remove automatic_copilot_code_review_enabled")
714+
715+
elif rule.get("type") == "required_status_checks":
716+
params = rule.setdefault("parameters", {})
717+
checks = params.setdefault("required_status_checks", [])
718+
if not any(c.get("context") == "Migrate Repo Config" for c in checks):
719+
checks.append(
720+
{"context": "Migrate Repo Config", "integration_id": 15368}
721+
)
722+
changes.append("add 'Migrate Repo Config' status check")
723+
724+
if not changes:
725+
print(f" Ruleset '{rule_name}' is already up to date")
726+
return
727+
728+
# ── Push the update ───────────────────────────────────────────────────
729+
if not update_ruleset(ruleset_id, ruleset):
730+
manual_step(
731+
f"Failed to update the '{rule_name}' ruleset via the GitHub API. "
732+
f"Please apply the following changes manually at {ruleset_url}: "
733+
+ "; ".join(changes)
734+
)
735+
return
736+
737+
print(f" Updated ruleset '{rule_name}': " + ", ".join(changes))
738+
739+
740+
def find_ruleset(name: str) -> dict[str, Any] | None:
741+
"""Find a repository ruleset by name using the GitHub API.
742+
743+
Args:
744+
name: The name of the ruleset to search for.
745+
746+
Returns:
747+
The ruleset summary dict (id, name, …) if found, or ``None`` if not
748+
found or if the API call failed (a diagnostic is printed in the latter
749+
case).
750+
"""
751+
try:
752+
stdout = subprocess.check_output(
753+
["gh", "api", "repos/:owner/:repo/rulesets"],
754+
text=True,
755+
stderr=subprocess.PIPE,
756+
)
757+
except FileNotFoundError:
758+
print(" gh CLI not found; cannot query rulesets via the GitHub API.")
759+
return None
760+
except subprocess.CalledProcessError as exc:
761+
print(f" Failed to list rulesets: {exc.stderr.strip()}")
762+
return None
763+
764+
rulesets: list[dict[str, Any]] = json.loads(stdout)
765+
return next((r for r in rulesets if r.get("name") == name), None)
766+
767+
768+
def get_ruleset(ruleset: str | int) -> dict[str, Any] | None:
769+
"""Fetch the full details of a repository ruleset by name or ID.
770+
771+
Args:
772+
ruleset: The ruleset name (``str``) or numeric ruleset ID (``int``).
773+
774+
Returns:
775+
The full ruleset dict, or ``None`` if the ruleset could not be found
776+
or the API call failed (a diagnostic is printed).
777+
"""
778+
ruleset_id = ruleset
779+
if isinstance(ruleset, str):
780+
entry = find_ruleset(ruleset)
781+
if entry is None:
782+
return None
783+
ruleset_id = entry["id"]
784+
785+
try:
786+
stdout = subprocess.check_output(
787+
["gh", "api", f"repos/:owner/:repo/rulesets/{ruleset_id}"],
788+
text=True,
789+
stderr=subprocess.PIPE,
790+
)
791+
except subprocess.CalledProcessError as exc:
792+
print(f" Failed to fetch ruleset {ruleset_id}: {exc.stderr.strip()}")
793+
return None
794+
795+
return json.loads(stdout) # type: ignore[no-any-return]
796+
797+
798+
def update_ruleset(ruleset_id: int, config: dict[str, Any]) -> bool:
799+
"""Update a repository ruleset via the GitHub API.
800+
801+
Only ``name``, ``target``, ``enforcement``, ``conditions``, ``rules``,
802+
and ``bypass_actors`` are sent (explicit allowlist to avoid sending
803+
read-only fields back to the API).
804+
805+
Args:
806+
ruleset_id: The numeric ruleset ID to update.
807+
config: The full ruleset dict (as returned by :func:`get_ruleset`)
808+
with the desired changes already applied in-memory.
809+
810+
Returns:
811+
``True`` on success, ``False`` if the API call failed (a diagnostic
812+
is printed).
813+
"""
814+
payload: dict[str, Any] = {
815+
"name": config["name"],
816+
"target": config["target"],
817+
"enforcement": config["enforcement"],
818+
"conditions": config["conditions"],
819+
"rules": config["rules"],
820+
}
821+
if "bypass_actors" in config:
822+
payload["bypass_actors"] = config["bypass_actors"]
823+
824+
try:
825+
subprocess.check_output(
826+
[
827+
"gh",
828+
"api",
829+
"-X",
830+
"PUT",
831+
f"repos/:owner/:repo/rulesets/{ruleset_id}",
832+
"--input",
833+
"-",
834+
],
835+
input=json.dumps(payload),
836+
text=True,
837+
stderr=subprocess.PIPE,
838+
)
839+
except subprocess.CalledProcessError as exc:
840+
print(f" Failed to update ruleset {ruleset_id}: {exc.stderr.strip()}")
841+
return False
842+
843+
return True
844+
845+
846+
def get_ruleset_settings_url() -> str | None:
847+
"""Return the URL to the repository's ruleset settings page.
848+
849+
Returns:
850+
The URL as a string, or ``None`` if it could not be determined.
851+
"""
852+
try:
853+
stdout = subprocess.check_output(
854+
["gh", "repo", "view", "--json", "owner,name"],
855+
text=True,
856+
stderr=subprocess.PIPE,
857+
)
858+
info: dict[str, Any] = json.loads(stdout)
859+
org = info["owner"]["login"]
860+
repo = info["name"]
861+
return f"https://github.com/{org}/{repo}/settings/rules"
862+
except (subprocess.CalledProcessError, KeyError, json.JSONDecodeError):
863+
return None
864+
865+
652866
def read_project_type() -> str | None:
653867
"""Read the cookiecutter project type from the replay file."""
654868
replay_path = Path(".cookiecutter-replay.json")

0 commit comments

Comments
 (0)