diff --git a/.github/repo-settings.json b/.github/repo-settings.json new file mode 100644 index 0000000..a81a5cf --- /dev/null +++ b/.github/repo-settings.json @@ -0,0 +1,10 @@ +{ + "delete_branch_on_merge": true, + "allow_auto_merge": false, + "allow_squash_merge": true, + "allow_merge_commit": true, + "allow_rebase_merge": true, + "has_wiki": false, + "has_projects": false, + "web_commit_signoff_required": false +} diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json new file mode 100644 index 0000000..5301924 --- /dev/null +++ b/.github/rulesets/main.json @@ -0,0 +1,43 @@ +{ + "name": "protect-main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { "context": "Render and validate both variants" }, + { "context": "Smoke-test python-ci.yml / Lint, type-check, and test" }, + { "context": "Smoke-test node-ci.yml / Type-check and build" } + ] + } + } + ] +} diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index 28cc72a..527ed87 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -58,6 +58,32 @@ jobs: # raising default_language_version is the regression to catch. python3 -c "import sys,yaml; cfg=yaml.safe_load(open(sys.argv[1])); hs=[h for r in cfg['repos'] if 'shellcheck-py' in r['repo'] for h in r['hooks'] if h['id']=='shellcheck']; assert len(hs)==1, hs; v=hs[0].get('language_version'); assert v=='python3.12', f'shellcheck language_version is {v!r}'" "/tmp/out-$kind/.pre-commit-config.yaml" done + # Repo settings as code is opt-in: the defaults carry none of it, so a + # `copier update --defaults` cannot push it into downstream repos. + for kind in app library; do + test ! -e "/tmp/out-$kind/scripts" + test ! -e "/tmp/out-$kind/.github/rulesets" + test ! -e "/tmp/out-$kind/.github/repo-settings.json" + done + # The applier is tested against a stubbed gh, here in the template repo + # (the file's path has copier syntax in it, so the test loads it by path). + uvx --with pytest --from pytest pytest -q tests/test_setup_repo.py + uvx copier copy --trust --defaults --vcs-ref=HEAD \ + --data project_name="sample-repo-settings" \ + --data project_description="A sample managing its repo settings" \ + --data use_repo_settings=true \ + . /tmp/out-repo-settings + # Apply script plus JSON payloads render intact, and the script keeps + # its executable bit. + test -x /tmp/out-repo-settings/scripts/setup_repo.py + python3 -m py_compile /tmp/out-repo-settings/scripts/setup_repo.py + /tmp/out-repo-settings/scripts/setup_repo.py --help > /dev/null + python3 -c "import json,sys; json.load(open(sys.argv[1]))" /tmp/out-repo-settings/.github/rulesets/main.json + python3 -c "import json,sys; json.load(open(sys.argv[1]))" /tmp/out-repo-settings/.github/repo-settings.json + grep -q 'ci / Lint, type-check, and test' /tmp/out-repo-settings/.github/rulesets/main.json + ! grep -q 'frontend /' /tmp/out-repo-settings/.github/rulesets/main.json + echo "repo settings opt-in honoured." + # library-only files present for library, absent for app test -f /tmp/out-library/RELEASING.md test -f /tmp/out-library/.github/workflows/publish.yml @@ -81,12 +107,16 @@ jobs: --data project_name="sample-frontend" \ --data project_description="A sample with a frontend" \ --data use_frontend=true \ + --data use_repo_settings=true \ . /tmp/out-frontend test -f /tmp/out-frontend/biome.json grep -q "biomejs/pre-commit" /tmp/out-frontend/.pre-commit-config.yaml grep -q "node-ci.yml" /tmp/out-frontend/.github/workflows/ci.yml python3 -c "import json; json.load(open('/tmp/out-frontend/biome.json'))" python3 -c "import yaml; yaml.safe_load(open('/tmp/out-frontend/.github/workflows/ci.yml'))" + # and its ruleset requires the frontend job too + python3 -c "import json,sys; json.load(open(sys.argv[1]))" /tmp/out-frontend/.github/rulesets/main.json + grep -q 'frontend / Type-check and build' /tmp/out-frontend/.github/rulesets/main.json # `recommended` is deprecated in Biome 2.5.5; `preset` is the spelling # that doesn't emit a notice. grep -q '"preset": "recommended"' /tmp/out-frontend/biome.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9651e42..2125acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,22 @@ Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact, ## [Unreleased] +### Added + +- Repo settings as code, opt-in via `use_repo_settings` (default off, so + `copier update --defaults` leaves existing projects alone): the scaffold + ships `.github/repo-settings.json` + (the literal `PATCH /repos/{owner}/{repo}` body: merge methods, + `delete_branch_on_merge: true` so stacked PRs retarget, wiki/projects off) + and `.github/rulesets/main.json` (protect the default branch: PRs required, + no force-pushes or deletion, the `ci / Lint, type-check, and test` check + required, plus the frontend check when there is one; repository Admins + bypass), and `scripts/setup_repo.py`, which fetches the repo's current + settings and rulesets, prints what would change (a unified diff per ruleset, + projected onto the keys the file sets), and applies only after confirmation + or `--yes`; `--dry-run` only shows. The post-copy message points at it, and + this repo carries and applies its own copies. + ### Changed - Rebranded for the Generality-Labs fork: `github_owner` now defaults to diff --git a/README.md b/README.md index ac2ef69..edc52cf 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,9 @@ uvx copier copy gh:Generality-Labs/python-project-template my-new-project You'll be asked for the name, description, whether it's an app or a library, Python version, whether to enable a coverage gate, whether the project has a -TypeScript/JavaScript frontend, whether to run the typos spell-checker, and -whether to open template-update PRs automatically. +TypeScript/JavaScript frontend, whether to run the typos spell-checker, whether to +open template-update PRs automatically, and whether to manage repo settings +and a branch ruleset from files (off by default). ### Turning off `typos` @@ -136,6 +137,59 @@ change between releases. Turn them on per-project when you want them, and keep `tsc --noEmit` under `strict` as the backstop either way — Biome's inference is newer and less complete than a full type-checker's. +## Repo settings as code + +GitHub keeps repository settings and rulesets in the UI and API rather than in +files, so the scaffold can ship the files *and* the thing that applies them. +This is **opt-in**: answer yes to `use_repo_settings` (default no). Nothing +changes on GitHub until someone runs the script, but the default is off so a +`copier update` never drops the files, or the invitation to run them, into a +downstream repo that didn't ask. An existing project opts in by setting +`use_repo_settings: true` in `.copier-answers.yml` and running `copier update`. + +- `.github/repo-settings.json` is sent verbatim as the body of + `PATCH /repos/{owner}/{repo}`, so any key [that endpoint + accepts](https://docs.github.com/rest/repos/repos#update-a-repository) can be + managed there: merge methods, `has_wiki` / `has_projects`, and notably + `delete_branch_on_merge: true` (stacked PRs only retarget when merged base + branches are deleted). If a key turns out to be plan-gated for a repo the + whole PATCH 403s; remove the key and re-run. +- `.github/rulesets/*.json` are rulesets in the exact shape the GitHub UI + imports and exports (Settings -> Rules -> Rulesets), so they round-trip + through the dashboard. +- `scripts/setup_repo.py` (standard library only; needs `gh` authenticated + as a repo admin) applies both. It first fetches what the repo has now and + prints the difference: settings keys whose value would change, and a unified + diff of each ruleset against GitHub's copy, projected onto the keys the file + sets so ids, timestamps and GitHub's filled-in defaults never show as + changes. Nothing is applied until you confirm; `--dry-run` only shows, + `--yes` skips the prompt (and is required when not run from a terminal). + Re-runs are safe: unchanged keys are skipped and rulesets are updated in + place by name, never duplicated. + +The scaffolded `protect-main` ruleset stops deletion and force-pushes of the +default branch, requires changes to arrive by PR (0 approvals, so a solo +maintainer isn't blocked), and requires the `ci / Lint, type-check, and test` +check (plus `frontend / Type-check and build` when the project has a frontend). +Repository **admins bypass it** (`actor_id: 5` is the built-in Admin role) so a +release commit can still be pushed directly; tighten that as the team grows. +A required check only takes effect once it has run at least once on the repo, +so run CI before you rely on it. + +Plan gating: the rulesets API refuses private repos on the Free plan (403). The +script applies the plain settings, says so, and exits 0; re-run it after the +plan changes. Generality-Labs is on Team, so org repos are unaffected. + +This repo carries its own copies of both files and applies them with the +scaffolded script, from the repo root: + +```bash +python3 'template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py' Generality-Labs/python-project-template +``` + +The script is unit-tested against a stubbed `gh` in `tests/test_setup_repo.py`, +which template CI runs. + ## Keeping projects up to date Answer yes to `use_template_update` (the default) and the scaffold gets a diff --git a/copier.yml b/copier.yml index 87e1887..9471a8d 100644 --- a/copier.yml +++ b/copier.yml @@ -16,6 +16,12 @@ _message_after_copy: | uv sync uv run pre-commit install git init && git add -A && git commit -m "Initial commit" + {% if use_repo_settings %} + + Once the repo exists on GitHub, review and apply its settings and branch + ruleset (shows what would change first; edit .github/rulesets/*.json if needed): + scripts/setup_repo.py + {% endif %} project_name: type: str @@ -90,6 +96,17 @@ use_template_update: help: Open a PR automatically when the template changes? (weekly `copier update`) default: true +use_repo_settings: + type: bool + # Scaffolds .github/repo-settings.json, .github/rulesets/main.json and + # scripts/setup_repo.py, which apply repo settings and a protect-main + # ruleset through the GitHub API when someone runs the script. Off by + # default so `copier update --defaults` never drops the files (and the + # invitation to run them) into a downstream repo that didn't ask; a repo + # opts in by answering yes here or by editing .copier-answers.yml. + help: Manage GitHub repo settings and a protect-main ruleset from files in the repo? (adds scripts/setup_repo.py; nothing is applied until you run it) + default: false + coverage_floor: type: int help: Minimum coverage percentage diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..c03cdf2 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,43 @@ +# Ruff settings for this repo's own Python: the scaffolded scripts under +# template/ and the tests that exercise them. Mirrors the [tool.ruff] block in +# template/pyproject.toml.jinja so a file formats identically here and in a +# scaffolded project. Without this, pre-commit in the smoke-test job formats +# these files with ruff's defaults (line length 88) and disagrees with the +# scaffold's 100. +line-length = 100 +target-version = "py311" + +[lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "D", # pydocstyle + "C4", # flake8-comprehensions + "PT", # flake8-pytest-style + "PIE", # flake8-pie + "DTZ", # flake8-datetimez (timezone-aware datetimes) + "ISC", # implicit-str-concat (catches missing commas) + "ASYNC", # flake8-async + "N", # pep8-naming + "FURB", # refurb (modernization) + "RUF", # ruff-specific rules + "PLE", # pylint errors + "PLW", # pylint warnings +] +ignore = [ + "E501", # line-too-long: the formatter owns line length + "D10", # missing docstrings + "D415", # first-line punctuation + "ISC001", # single-line implicit concat conflicts with the formatter +] + +[lint.pydocstyle] +convention = "google" + +[lint.per-file-ignores] +"tests/**" = ["D"] diff --git a/template/.github/{% if use_repo_settings %}repo-settings.json{% endif %} b/template/.github/{% if use_repo_settings %}repo-settings.json{% endif %} new file mode 100644 index 0000000..a81a5cf --- /dev/null +++ b/template/.github/{% if use_repo_settings %}repo-settings.json{% endif %} @@ -0,0 +1,10 @@ +{ + "delete_branch_on_merge": true, + "allow_auto_merge": false, + "allow_squash_merge": true, + "allow_merge_commit": true, + "allow_rebase_merge": true, + "has_wiki": false, + "has_projects": false, + "web_commit_signoff_required": false +} diff --git a/template/.github/{% if use_repo_settings %}rulesets{% endif %}/main.json.jinja b/template/.github/{% if use_repo_settings %}rulesets{% endif %}/main.json.jinja new file mode 100644 index 0000000..6c67602 --- /dev/null +++ b/template/.github/{% if use_repo_settings %}rulesets{% endif %}/main.json.jinja @@ -0,0 +1,42 @@ +{ + "name": "protect-main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { "context": "ci / Lint, type-check, and test" }{% if use_frontend %}, + { "context": "frontend / Type-check and build" }{% endif %} + ] + } + } + ] +} diff --git a/template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py b/template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py new file mode 100755 index 0000000..f57f15c --- /dev/null +++ b/template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""Apply this repo's GitHub settings and rulesets from files in the repo. + +GitHub keeps repository settings and rulesets in the UI and API rather than in +checked-in files, so this script is the applier for two kinds of file: + +1. ``.github/repo-settings.json``: keys for ``PATCH /repos/{owner}/{repo}`` + (merge methods, ``delete_branch_on_merge``, ``has_wiki``, ...). Any key that + endpoint accepts can be set. Full list: + https://docs.github.com/rest/repos/repos#update-a-repository +2. ``.github/rulesets/*.json``: rulesets in the shape the GitHub UI imports and + exports (Settings -> Rules -> Rulesets). Each is created if no ruleset with + its name exists, otherwise updated in place, so re-runs never duplicate. + +Before changing anything it fetches what the repo has now and prints the +difference: settings keys whose value would change, and a unified diff of each +ruleset against GitHub's copy projected onto the keys the file sets, so ids, +timestamps and the defaults GitHub fills in never show up as changes. Nothing +is applied until you confirm, or pass ``--yes``. + +The rulesets API is plan-gated for private repos (Free plan: 403 "Upgrade to +GitHub Pro..."). Settings are still applied; rulesets are skipped with a note. + +Usage:: + + scripts/setup_repo.py [--dry-run | --yes] [OWNER/REPO] + +``OWNER/REPO`` defaults to the repo behind the ``origin`` remote (never gh's +default-repo guess, which can pick the wrong one on a multi-remote checkout). +Needs ``gh`` authenticated as a repo admin. Standard library only. + +Exit codes: 0 applied or nothing to do, 1 aborted at the prompt or a GitHub +error, 2 usage error or a non-terminal run without ``--yes``. +""" + +from __future__ import annotations + +import argparse +import difflib +import json +import subprocess +import sys +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, cast + +SETTINGS_FILE = Path(".github/repo-settings.json") +RULESETS_DIR = Path(".github/rulesets") +PLAN_GATE_MARKER = "Upgrade to GitHub" + +Json = Any +Runner = Callable[[Sequence[str], str | None], str] + + +class GhError(RuntimeError): + """``gh`` exited non-zero; the message is its stderr.""" + + +def run_gh(args: Sequence[str], stdin: str | None = None) -> str: + """Run ``gh`` with ``args`` and return stdout; raise :class:`GhError` on failure.""" + result = subprocess.run(["gh", *args], input=stdin, capture_output=True, text=True, check=False) + if result.returncode != 0: + raise GhError((result.stderr or result.stdout).strip()) + return result.stdout + + +def project(current: Json, want: Json) -> Json: + """Keep only the parts of ``current`` that ``want`` also has. + + Objects are filtered by key and arrays paired by index, recursively. Leaves + keep their ``current`` values so a diff shows what GitHub has now. An array + element GitHub has but ``want`` lacks is kept, so it shows as a difference. + """ + if isinstance(want, dict) and isinstance(current, dict): + want_map = cast(dict[str, Json], want) + current_map = cast(dict[str, Json], current) + return {k: project(v, want_map[k]) for k, v in current_map.items() if k in want_map} + if isinstance(want, list) and isinstance(current, list): + want_list = cast(list[Json], want) + current_list = cast(list[Json], current) + return [ + project(v, want_list[i] if i < len(want_list) else None) + for i, v in enumerate(current_list) + ] + return current + + +def canonical(value: Json) -> str: + return json.dumps(value, indent=2, sort_keys=True) + "\n" + + +@dataclass +class SettingChange: + key: str + current: Json + desired: Json + + +@dataclass +class RulesetPlan: + path: Path + name: str + desired: Json + existing_id: int | None + diff: str # empty when unchanged or when creating + + @property + def changed(self) -> bool: + return self.existing_id is None or bool(self.diff) + + +@dataclass +class Plan: + repo: str + settings: list[SettingChange] = field(default_factory=list) + settings_file_present: bool = True + rulesets: list[RulesetPlan] = field(default_factory=list) + rulesets_gated: bool = False + + @property + def any_change(self) -> bool: + return bool(self.settings) or any(r.changed for r in self.rulesets) + + +def resolve_repo(explicit: str | None, gh: Runner) -> str: + if explicit: + return explicit + origin = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=False, + ) + view_args = ["repo", "view"] + if origin.returncode == 0 and origin.stdout.strip(): + view_args.append(origin.stdout.strip()) + return gh([*view_args, "--json", "nameWithOwner", "--jq", ".nameWithOwner"], None).strip() + + +def plan_settings(repo: str, gh: Runner, root: Path) -> tuple[list[SettingChange], bool]: + path = root / SETTINGS_FILE + if not path.exists(): + return [], False + desired: dict[str, Json] = json.loads(path.read_text()) + current: dict[str, Json] = json.loads(gh(["api", f"repos/{repo}"], None)) + return [ + SettingChange(key, current.get(key), value) + for key, value in desired.items() + if current.get(key) != value + ], True + + +def plan_rulesets(repo: str, gh: Runner, root: Path) -> tuple[list[RulesetPlan], bool]: + files = sorted((root / RULESETS_DIR).glob("*.json")) + if not files: + return [], False + try: + existing: list[dict[str, Json]] = json.loads( + gh(["api", f"repos/{repo}/rulesets?per_page=100"], None) + ) + except GhError as e: + if PLAN_GATE_MARKER in str(e): + return [], True + raise + by_name = {r["name"]: int(r["id"]) for r in existing} + + plans: list[RulesetPlan] = [] + for path in files: + desired = json.loads(path.read_text()) + name = str(desired["name"]) + existing_id = by_name.get(name) + diff = "" + if existing_id is not None: + current = json.loads(gh(["api", f"repos/{repo}/rulesets/{existing_id}"], None)) + diff = "".join( + difflib.unified_diff( + canonical(project(current, desired)).splitlines(keepends=True), + canonical(desired).splitlines(keepends=True), + fromfile=f"{name} (current, as reported by GitHub)", + tofile=f"{name} ({path})", + ) + ) + plans.append(RulesetPlan(path, name, desired, existing_id, diff)) + return plans, False + + +def build_plan(repo: str, gh: Runner, root: Path = Path()) -> Plan: + settings, present = plan_settings(repo, gh, root) + rulesets, gated = plan_rulesets(repo, gh, root) + return Plan(repo, settings, present, rulesets, gated) + + +def describe(plan: Plan, out: Callable[[str], None]) -> None: + out(f"== GitHub settings for {plan.repo} ==") + if not plan.settings_file_present: + out(f" note: {SETTINGS_FILE} not found; skipping repo settings") + elif not plan.settings: + out(f" repo settings: no changes ({SETTINGS_FILE} already matches)") + else: + out(f" repo settings: {len(plan.settings)} change(s)") + for change in plan.settings: + out(f" {change.key}: {json.dumps(change.current)} -> {json.dumps(change.desired)}") + if plan.rulesets_gated: + out(" note: the rulesets API refuses private repos on this GitHub plan") + out(f" (needs Pro/Team+); {RULESETS_DIR}/*.json skipped") + for rs in plan.rulesets: + if rs.existing_id is None: + out(f" ruleset '{rs.name}': does not exist yet; would be created from {rs.path}") + elif rs.diff: + out(f" ruleset '{rs.name}' (id {rs.existing_id}): would be updated") + for line in rs.diff.rstrip("\n").splitlines(): + out(f" {line}") + else: + out(f" ruleset '{rs.name}' (id {rs.existing_id}): no changes") + + +def apply(plan: Plan, gh: Runner, out: Callable[[str], None]) -> None: + if plan.settings: + out("+ repo settings") + body = {c.key: c.desired for c in plan.settings} + gh( + ["api", "-X", "PATCH", f"repos/{plan.repo}", "--input", "-"], + json.dumps(body), + ) + for rs in plan.rulesets: + if not rs.changed: + continue + if rs.existing_id is None: + out(f"+ create ruleset '{rs.name}' from {rs.path}") + gh( + ["api", "-X", "POST", f"repos/{plan.repo}/rulesets", "--input", "-"], + canonical(rs.desired), + ) + else: + out(f"+ update ruleset '{rs.name}' (id {rs.existing_id}) from {rs.path}") + gh( + [ + "api", + "-X", + "PUT", + f"repos/{plan.repo}/rulesets/{rs.existing_id}", + "--input", + "-", + ], + canonical(rs.desired), + ) + out("") + out("Applied. Settings that stay manual:") + out(" - GitHub environments and their reviewers/secrets (e.g. the 'pypi'") + out(" environment used by publish.yml for trusted publishing)") + out(" - required status checks only take effect once the named check has run at") + out(" least once on the repository") + + +def main( + argv: Sequence[str] | None = None, + gh: Runner = run_gh, + out: Callable[[str], None] = print, + interactive: bool | None = None, + ask: Callable[[str], str] = input, +) -> int: + parser = argparse.ArgumentParser( + description=(__doc__ or "").split("\n\n")[0], + epilog="See the module docstring for details.", + ) + parser.add_argument( + "repo", + nargs="?", + metavar="OWNER/REPO", + help="target repo (default: the 'origin' remote)", + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--dry-run", + action="store_true", + help="show the differences and exit without applying", + ) + mode.add_argument( + "--yes", + "-y", + action="store_true", + help="apply without asking (required when not a terminal)", + ) + args = parser.parse_args(argv) + + try: + gh(["auth", "status"], None) + except GhError: + out("error: gh is not authenticated (run: gh auth login)") + return 1 + + try: + repo = resolve_repo(args.repo, gh) + plan = build_plan(repo, gh) + except GhError as e: + out(f"error: {e}") + return 1 + + describe(plan, out) + if not plan.any_change: + out("Nothing to apply.") + return 0 + if args.dry_run: + out("Dry run; nothing applied.") + return 0 + if not args.yes: + if interactive is None: + interactive = sys.stdin.isatty() + if not interactive: + out("Not a terminal and --yes not given; nothing applied. Re-run with --yes to apply.") + return 2 + if ask(f"Apply these changes to {repo}? [y/N] ").strip().lower() not in ( + "y", + "yes", + ): + out("Aborted; nothing applied.") + return 1 + + try: + apply(plan, gh, out) + except GhError as e: + out(f"error: {e}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_setup_repo.py b/tests/test_setup_repo.py new file mode 100644 index 0000000..35162fa --- /dev/null +++ b/tests/test_setup_repo.py @@ -0,0 +1,215 @@ +"""Tests for the scaffolded scripts/setup_repo.py against a stubbed gh.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from collections.abc import Sequence +from pathlib import Path + +import pytest + +SCRIPT = ( + Path(__file__).resolve().parents[1] + / "template" + / "{% if use_repo_settings %}scripts{% endif %}" + / "setup_repo.py" +) +spec = importlib.util.spec_from_file_location("setup_repo", SCRIPT) +assert spec is not None +assert spec.loader is not None +setup_repo = importlib.util.module_from_spec(spec) +# Dataclasses resolve postponed annotations through sys.modules[__module__]. +sys.modules[spec.name] = setup_repo +spec.loader.exec_module(setup_repo) + +REPO = "acme/widgets" +SETTINGS = {"delete_branch_on_merge": True, "has_wiki": False} +RULESET = { + "name": "protect-main", + "target": "branch", + "enforcement": "active", + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "bypass_actors": [{"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"}], + "rules": [ + {"type": "deletion"}, + {"type": "pull_request", "parameters": {"required_approving_review_count": 0}}, + ], +} + + +def as_github_returns(ruleset: dict, ruleset_id: int) -> dict: + """GitHub's copy: same content plus ids, timestamps and defaults for unset parameters.""" + copy = json.loads(json.dumps(ruleset)) + copy.update({"id": ruleset_id, "source": REPO, "created_at": "2026-01-01T00:00:00Z"}) + for rule in copy["rules"]: + if rule["type"] == "pull_request": + rule["parameters"]["dismiss_stale_reviews_on_push"] = False + rule["parameters"]["allowed_merge_methods"] = ["merge", "squash", "rebase"] + return copy + + +class FakeGh: + """Records every gh call and answers reads from a small in-memory GitHub.""" + + def __init__(self, settings: dict, rulesets: list[dict]) -> None: + self.settings = settings + self.rulesets = rulesets + self.writes: list[tuple[list[str], str | None]] = [] + self.plan_gated = False + + def __call__(self, args: Sequence[str], stdin: str | None) -> str: + args = list(args) + if args[:2] == ["auth", "status"]: + return "" + if args[:2] == ["repo", "view"]: + return REPO + "\n" + if "-X" in args: + self.writes.append((args, stdin)) + return "{}" + target = args[1] + if target == f"repos/{REPO}": + return json.dumps(self.settings) + if target.startswith(f"repos/{REPO}/rulesets?"): + if self.plan_gated: + raise setup_repo.GhError( + "HTTP 403: Upgrade to GitHub Pro or make this repository public" + ) + return json.dumps([{"id": r["id"], "name": r["name"]} for r in self.rulesets]) + if target.startswith(f"repos/{REPO}/rulesets/"): + wanted = int(target.rsplit("/", 1)[1]) + return json.dumps(next(r for r in self.rulesets if r["id"] == wanted)) + raise AssertionError(f"unexpected gh call: {args}") + + +@pytest.fixture +def repo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + (tmp_path / ".github" / "rulesets").mkdir(parents=True) + (tmp_path / ".github" / "repo-settings.json").write_text(json.dumps(SETTINGS)) + (tmp_path / ".github" / "rulesets" / "main.json").write_text(json.dumps(RULESET)) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def run(gh: FakeGh, *argv: str, interactive: bool = False, answer: str = "n") -> tuple[int, str]: + lines: list[str] = [] + code = setup_repo.main( + [*argv, REPO], + gh=gh, + out=lines.append, + interactive=interactive, + ask=lambda _: answer, + ) + return code, "\n".join(lines) + + +def test_project_drops_api_only_fields_but_keeps_extra_array_elements() -> None: + current = { + "id": 1, + "a": {"x": 1, "y": 2}, + "rules": [{"t": "a", "extra": 1}, {"t": "b"}], + } + want = {"a": {"x": 0}, "rules": [{"t": "a"}]} + assert setup_repo.project(current, want) == { + "a": {"x": 1}, + "rules": [{"t": "a"}, {"t": "b"}], + } + + +def test_nothing_to_apply_when_everything_matches(repo_dir: Path) -> None: + gh = FakeGh(dict(SETTINGS, description="x"), [as_github_returns(RULESET, 7)]) + code, out = run(gh, "--yes") + assert code == 0 + assert "no changes (.github/repo-settings.json already matches)" in out + assert "ruleset 'protect-main' (id 7): no changes" in out + assert "Nothing to apply." in out + assert gh.writes == [] + + +def test_first_run_creates_ruleset_and_patches_only_changed_keys( + repo_dir: Path, +) -> None: + gh = FakeGh({"delete_branch_on_merge": False, "has_wiki": False}, []) + code, out = run(gh, "--yes") + assert code == 0 + assert "repo settings: 1 change(s)" in out + assert "delete_branch_on_merge: false -> true" in out + assert "does not exist yet; would be created" in out + (patch_args, patch_body), (post_args, post_body) = gh.writes + assert patch_args[:4] == ["api", "-X", "PATCH", f"repos/{REPO}"] + assert json.loads(patch_body or "") == {"delete_branch_on_merge": True} + assert post_args[:4] == ["api", "-X", "POST", f"repos/{REPO}/rulesets"] + assert json.loads(post_body or "") == RULESET + + +def test_drift_shows_diff_and_updates_in_place(repo_dir: Path) -> None: + drifted = as_github_returns(RULESET, 9) + drifted["rules"][1]["parameters"]["required_approving_review_count"] = 2 + gh = FakeGh(SETTINGS, [drifted]) + code, out = run(gh, "--yes") + assert code == 0 + assert "(id 9): would be updated" in out + assert '- "required_approving_review_count": 2' in out + assert '+ "required_approving_review_count": 0' in out + assert "allowed_merge_methods" not in out # API-only default, not a difference + assert len(gh.writes) == 1 + assert gh.writes[0][0][:4] == ["api", "-X", "PUT", f"repos/{REPO}/rulesets/9"] + + +def test_dry_run_applies_nothing(repo_dir: Path) -> None: + gh = FakeGh({}, []) + code, out = run(gh, "--dry-run") + assert code == 0 + assert "Dry run; nothing applied." in out + assert gh.writes == [] + + +def test_non_interactive_without_yes_refuses(repo_dir: Path) -> None: + gh = FakeGh({}, []) + code, out = run(gh) + assert code == 2 + assert "Re-run with --yes" in out + assert gh.writes == [] + + +def test_prompt_no_aborts_and_yes_applies(repo_dir: Path) -> None: + gh = FakeGh({}, []) + code, out = run(gh, interactive=True, answer="n") + assert code == 1 + assert "Aborted" in out + assert gh.writes == [] + code, _ = run(gh, interactive=True, answer="y") + assert code == 0 + assert len(gh.writes) == 2 + + +def test_plan_gate_skips_rulesets_but_applies_settings(repo_dir: Path) -> None: + gh = FakeGh({}, []) + gh.plan_gated = True + code, out = run(gh, "--yes") + assert code == 0 + assert "refuses private repos" in out + assert len(gh.writes) == 1 + assert gh.writes[0][0][2] == "PATCH" + + +def test_missing_settings_file_is_skipped(repo_dir: Path) -> None: + (repo_dir / ".github" / "repo-settings.json").unlink() + gh = FakeGh({}, []) + code, out = run(gh, "--yes") + assert code == 0 + assert "not found; skipping repo settings" in out + assert [w[0][2] for w in gh.writes] == ["POST"] + + +def test_explicit_repo_wins_over_origin(repo_dir: Path) -> None: + gh = FakeGh(SETTINGS, [as_github_returns(RULESET, 1)]) + calls: list[list[str]] = [] + + def spy(args: Sequence[str], stdin: str | None) -> str: + calls.append(list(args)) + return gh(args, stdin) + + assert setup_repo.main([REPO, "--yes"], gh=spy, out=lambda _: None) == 0 + assert not any(c[:2] == ["repo", "view"] for c in calls)