From d10ddcecb7a61efc83991f425d4db54a814067b6 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 11 Sep 2026 12:43:08 +1000 Subject: [PATCH 1/6] Add repo settings as code: rulesets, settings JSON and setup-repo.sh Scaffolds .github/repo-settings.json, .github/rulesets/main.json and scripts/setup-repo.sh so a new repo's default branch gets protected from files in the repo rather than by hand in the GitHub UI. Ported from the worker template's open PR and adapted to this template's CI check names; the template repo carries and applies its own copies. Co-Authored-By: Claude Fable 5.1 --- .github/repo-settings.json | 10 +++ .github/rulesets/main.json | 43 ++++++++++++ .github/workflows/template-ci.yml | 14 ++++ CHANGELOG.md | 12 ++++ README.md | 39 +++++++++++ copier.yml | 4 ++ template/.github/repo-settings.json | 10 +++ template/.github/rulesets/main.json.jinja | 42 ++++++++++++ template/scripts/setup-repo.sh | 83 +++++++++++++++++++++++ 9 files changed, 257 insertions(+) create mode 100644 .github/repo-settings.json create mode 100644 .github/rulesets/main.json create mode 100644 template/.github/repo-settings.json create mode 100644 template/.github/rulesets/main.json.jinja create mode 100755 template/scripts/setup-repo.sh 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..5bd1494 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -58,6 +58,17 @@ 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: apply script plus JSON payloads render intact, + # and the script keeps its executable bit. + for kind in app library; do + test -x "/tmp/out-$kind/scripts/setup-repo.sh" + bash -n "/tmp/out-$kind/scripts/setup-repo.sh" + python3 -c "import json,sys; json.load(open(sys.argv[1]))" "/tmp/out-$kind/.github/rulesets/main.json" + python3 -c "import json,sys; json.load(open(sys.argv[1]))" "/tmp/out-$kind/.github/repo-settings.json" + done + grep -q 'ci / Lint, type-check, and test' /tmp/out-app/.github/rulesets/main.json + ! grep -q 'frontend /' /tmp/out-app/.github/rulesets/main.json + # 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 @@ -87,6 +98,9 @@ jobs: 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..c3c1aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact, ## [Unreleased] +### Added + +- Repo settings as code: 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.sh`, which idempotently PATCHes the settings + and creates or updates each ruleset by name. 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..86bd49d 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,45 @@ 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 ships the files *and* the thing that applies them: + +- `.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.sh` applies both idempotently with your own `gh` auth + (repo admin needed): it PATCHes the settings file, then creates or + updates-in-place each ruleset by name. + +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 the same way, +from the repo root: + +```bash +template/scripts/setup-repo.sh +``` + ## 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..e1e4280 100644 --- a/copier.yml +++ b/copier.yml @@ -17,6 +17,10 @@ _message_after_copy: | uv run pre-commit install git init && git add -A && git commit -m "Initial commit" + Once the repo exists on GitHub, apply its settings and branch ruleset + (idempotent; edit .github/rulesets/*.json first if needed): + scripts/setup-repo.sh + project_name: type: str help: Project / repo name in kebab-case, e.g. "my-tool" diff --git a/template/.github/repo-settings.json b/template/.github/repo-settings.json new file mode 100644 index 0000000..a81a5cf --- /dev/null +++ b/template/.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/template/.github/rulesets/main.json.jinja b/template/.github/rulesets/main.json.jinja new file mode 100644 index 0000000..6c67602 --- /dev/null +++ b/template/.github/rulesets/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/scripts/setup-repo.sh b/template/scripts/setup-repo.sh new file mode 100755 index 0000000..54f3098 --- /dev/null +++ b/template/scripts/setup-repo.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Apply this repo's GitHub settings and rulesets: settings-as-code for the +# things GitHub keeps in the UI/API rather than in checked-in files. +# +# 1. .github/repo-settings.json is sent verbatim as the body of +# PATCH /repos/{owner}/{repo}, so any key that endpoint accepts can be +# set there (merge methods, delete_branch_on_merge, has_wiki, ...). +# Full key list: https://docs.github.com/rest/repos/repos#update-a-repository +# 2. Every ruleset JSON in .github/rulesets/ is created if absent, or +# updated in place when a ruleset with the same name already exists. +# The JSON is the shape the GitHub UI imports and exports +# (Settings -> Rules -> Rulesets), so it round-trips through the dashboard. +# +# Idempotent: safe to re-run after editing any of the JSON files. Needs `gh` +# authenticated as a repo admin, and `jq`. +# +# The rulesets API is plan-gated for private repos (Free plan: 403 "Upgrade to +# GitHub Pro..."). The script applies the plain settings, says so, and exits 0 +# so it can sit in a setup checklist without failing it. +# +# Usage (against the repo of the current checkout): +# scripts/setup-repo.sh +set -euo pipefail + +command -v jq > /dev/null || { + echo "error: jq is required (brew install jq)" >&2 + exit 1 +} +gh auth status > /dev/null 2>&1 || { + echo "error: gh is not authenticated (run: gh auth login)" >&2 + exit 1 +} + +repo="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" +echo "== GitHub settings for $repo ==" + +SETTINGS_FILE=".github/repo-settings.json" +if [[ -f "$SETTINGS_FILE" ]]; then + echo "+ repo settings from $SETTINGS_FILE" + gh api -X PATCH "repos/$repo" --input "$SETTINGS_FILE" > /dev/null +else + echo "note: $SETTINGS_FILE not found; skipping repo settings" +fi + +shopt -s nullglob +files=(.github/rulesets/*.json) +if [[ ${#files[@]} -eq 0 ]]; then + echo "no ruleset files under .github/rulesets/; done." + exit 0 +fi + +# One listing up front; each file is then a create (no ruleset with that +# name) or an in-place update (PUT by id), so re-runs never duplicate. +if ! existing="$(gh api "repos/$repo/rulesets?per_page=100" 2>&1)"; then + if grep -q "Upgrade to GitHub" <<< "$existing"; then + echo "note: the rulesets API refuses private repos on this GitHub plan" + echo " (needs Pro/Team+). Plain settings were applied; re-run this" + echo " script after a plan upgrade to apply .github/rulesets/*.json." + exit 0 + fi + printf '%s\n' "$existing" >&2 + exit 1 +fi +for f in "${files[@]}"; do + name="$(jq -r '.name' "$f")" + id="$(jq -r --arg name "$name" '.[] | select(.name == $name) | .id' <<< "$existing" | head -n 1)" + if [[ -n "$id" ]]; then + echo "+ update ruleset '$name' (id $id) from $f" + gh api -X PUT "repos/$repo/rulesets/$id" --input "$f" > /dev/null + else + echo "+ create ruleset '$name' from $f" + gh api -X POST "repos/$repo/rulesets" --input "$f" > /dev/null + fi +done + +cat << EOM + +Applied. Settings that stay manual: + - GitHub environments and their reviewers/secrets (e.g. the 'pypi' + environment used by publish.yml for trusted publishing) + - required status checks only take effect once the named check has run at + least once on the repository +EOM From 00e56d05776aa672bb16fd69aac57700ca9fc53a Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 11 Sep 2026 12:45:08 +1000 Subject: [PATCH 2/6] Resolve setup-repo.sh target from origin, with an explicit override gh's default-repo guess picked a fork on a checkout with two remotes and reconfigured it silently. Resolve the 'origin' URL instead, and accept an OWNER/REPO argument for cases like the template repo applying its own copies. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- template/scripts/setup-repo.sh | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 86bd49d..faeabe6 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ This repo carries its own copies of both files and applies them the same way, from the repo root: ```bash -template/scripts/setup-repo.sh +template/scripts/setup-repo.sh # targets the 'origin' remote; pass OWNER/REPO to override ``` ## Keeping projects up to date diff --git a/template/scripts/setup-repo.sh b/template/scripts/setup-repo.sh index 54f3098..01fa678 100755 --- a/template/scripts/setup-repo.sh +++ b/template/scripts/setup-repo.sh @@ -18,8 +18,9 @@ # GitHub Pro..."). The script applies the plain settings, says so, and exits 0 # so it can sit in a setup checklist without failing it. # -# Usage (against the repo of the current checkout): -# scripts/setup-repo.sh +# Usage: +# scripts/setup-repo.sh # the repo behind the 'origin' remote +# scripts/setup-repo.sh OWNER/REPO # an explicit target set -euo pipefail command -v jq > /dev/null || { @@ -31,7 +32,16 @@ gh auth status > /dev/null 2>&1 || { exit 1 } -repo="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" +# Resolve the target from 'origin' rather than gh's default-repo guess, which +# on a checkout with several remotes (a fork plus the org repo) can pick the +# wrong one and quietly reconfigure it. +if [[ $# -gt 0 ]]; then + repo="$1" +elif origin_url="$(git remote get-url origin 2> /dev/null)"; then + repo="$(gh repo view "$origin_url" --json nameWithOwner --jq .nameWithOwner)" +else + repo="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" +fi echo "== GitHub settings for $repo ==" SETTINGS_FILE=".github/repo-settings.json" From 4ae74d044fe8cb0b10627c71a74e5d24e7664f2b Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 11 Sep 2026 13:20:17 +1000 Subject: [PATCH 3/6] Make repo settings as code opt-in via use_repo_settings Off by default so copier update --defaults never lands the files, or the post-copy invitation to run them, in a downstream repo that didn't ask. Directory names carry the condition so declining leaves no empty dirs. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/template-ci.yml | 28 +++++++++++++------ CHANGELOG.md | 4 ++- README.md | 12 ++++++-- copier.yml | 13 +++++++++ ..._settings %}repo-settings.json{% endif %}} | 0 .../main.json.jinja | 0 .../setup-repo.sh | 0 7 files changed, 45 insertions(+), 12 deletions(-) rename template/.github/{repo-settings.json => {% if use_repo_settings %}repo-settings.json{% endif %}} (100%) rename template/.github/{rulesets => {% if use_repo_settings %}rulesets{% endif %}}/main.json.jinja (100%) rename template/{scripts => {% if use_repo_settings %}scripts{% endif %}}/setup-repo.sh (100%) diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index 5bd1494..b4a81a8 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -58,16 +58,27 @@ 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: apply script plus JSON payloads render intact, - # and the script keeps its executable bit. + # 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 -x "/tmp/out-$kind/scripts/setup-repo.sh" - bash -n "/tmp/out-$kind/scripts/setup-repo.sh" - python3 -c "import json,sys; json.load(open(sys.argv[1]))" "/tmp/out-$kind/.github/rulesets/main.json" - python3 -c "import json,sys; json.load(open(sys.argv[1]))" "/tmp/out-$kind/.github/repo-settings.json" + test ! -e "/tmp/out-$kind/scripts" + test ! -e "/tmp/out-$kind/.github/rulesets" + test ! -e "/tmp/out-$kind/.github/repo-settings.json" done - grep -q 'ci / Lint, type-check, and test' /tmp/out-app/.github/rulesets/main.json - ! grep -q 'frontend /' /tmp/out-app/.github/rulesets/main.json + 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.sh + bash -n /tmp/out-repo-settings/scripts/setup-repo.sh + 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 @@ -92,6 +103,7 @@ 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c3c1aac..10e3e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact, ### Added -- Repo settings as code: the scaffold ships `.github/repo-settings.json` +- 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, diff --git a/README.md b/README.md index faeabe6..e45a258 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` @@ -139,7 +140,12 @@ 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 ships the files *and* the thing that applies them: +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 diff --git a/copier.yml b/copier.yml index e1e4280..03069a6 100644 --- a/copier.yml +++ b/copier.yml @@ -16,10 +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, apply its settings and branch ruleset (idempotent; edit .github/rulesets/*.json first if needed): scripts/setup-repo.sh + {% endif %} project_name: type: str @@ -94,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.sh, 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.sh; nothing is applied until you run it) + default: false + coverage_floor: type: int help: Minimum coverage percentage diff --git a/template/.github/repo-settings.json b/template/.github/{% if use_repo_settings %}repo-settings.json{% endif %} similarity index 100% rename from template/.github/repo-settings.json rename to template/.github/{% if use_repo_settings %}repo-settings.json{% endif %} diff --git a/template/.github/rulesets/main.json.jinja b/template/.github/{% if use_repo_settings %}rulesets{% endif %}/main.json.jinja similarity index 100% rename from template/.github/rulesets/main.json.jinja rename to template/.github/{% if use_repo_settings %}rulesets{% endif %}/main.json.jinja diff --git a/template/scripts/setup-repo.sh b/template/{% if use_repo_settings %}scripts{% endif %}/setup-repo.sh similarity index 100% rename from template/scripts/setup-repo.sh rename to template/{% if use_repo_settings %}scripts{% endif %}/setup-repo.sh From fd6bbeb1beafa83e1c56e8b3fb1976e51d9fb940 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 11 Sep 2026 13:29:09 +1000 Subject: [PATCH 4/6] Rewrite setup-repo as Python: fetch, diff, confirm before applying The bash version had grown embedded jq programs and parallel arrays, and a jq input() misuse made every ruleset look new. The stdlib Python script fetches the repo's current settings and each ruleset, prints what would change (settings keys, and a unified diff of each ruleset projected onto the keys the file sets), and applies only after confirmation or --yes; --dry-run only shows. Unit-tested against a stubbed gh in template CI. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/template-ci.yml | 8 +- CHANGELOG.md | 8 +- README.md | 21 +- copier.yml | 10 +- .../setup-repo.sh | 93 ------ .../setup_repo.py | 315 ++++++++++++++++++ tests/test_setup_repo.py | 202 +++++++++++ 7 files changed, 548 insertions(+), 109 deletions(-) delete mode 100755 template/{% if use_repo_settings %}scripts{% endif %}/setup-repo.sh create mode 100755 template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py create mode 100644 tests/test_setup_repo.py diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index b4a81a8..527ed87 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -65,6 +65,9 @@ jobs: 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" \ @@ -72,8 +75,9 @@ jobs: . /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.sh - bash -n /tmp/out-repo-settings/scripts/setup-repo.sh + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 10e3e41..2125acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,11 @@ Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact, 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.sh`, which idempotently PATCHes the settings - and creates or updates each ruleset by name. The post-copy message points at - it, and this repo carries and applies its own copies. + 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 diff --git a/README.md b/README.md index e45a258..edc52cf 100644 --- a/README.md +++ b/README.md @@ -157,9 +157,15 @@ downstream repo that didn't ask. An existing project opts in by setting - `.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.sh` applies both idempotently with your own `gh` auth - (repo admin needed): it PATCHes the settings file, then creates or - updates-in-place each ruleset by name. +- `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 @@ -174,13 +180,16 @@ 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 the same way, -from the repo root: +This repo carries its own copies of both files and applies them with the +scaffolded script, from the repo root: ```bash -template/scripts/setup-repo.sh # targets the 'origin' remote; pass OWNER/REPO to override +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 03069a6..9471a8d 100644 --- a/copier.yml +++ b/copier.yml @@ -18,9 +18,9 @@ _message_after_copy: | git init && git add -A && git commit -m "Initial commit" {% if use_repo_settings %} - Once the repo exists on GitHub, apply its settings and branch ruleset - (idempotent; edit .github/rulesets/*.json first if needed): - scripts/setup-repo.sh + 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: @@ -99,12 +99,12 @@ use_template_update: use_repo_settings: type: bool # Scaffolds .github/repo-settings.json, .github/rulesets/main.json and - # scripts/setup-repo.sh, which apply repo settings and a protect-main + # 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.sh; nothing is applied until you run it) + 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: diff --git a/template/{% if use_repo_settings %}scripts{% endif %}/setup-repo.sh b/template/{% if use_repo_settings %}scripts{% endif %}/setup-repo.sh deleted file mode 100755 index 01fa678..0000000 --- a/template/{% if use_repo_settings %}scripts{% endif %}/setup-repo.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -# Apply this repo's GitHub settings and rulesets: settings-as-code for the -# things GitHub keeps in the UI/API rather than in checked-in files. -# -# 1. .github/repo-settings.json is sent verbatim as the body of -# PATCH /repos/{owner}/{repo}, so any key that endpoint accepts can be -# set there (merge methods, delete_branch_on_merge, has_wiki, ...). -# Full key list: https://docs.github.com/rest/repos/repos#update-a-repository -# 2. Every ruleset JSON in .github/rulesets/ is created if absent, or -# updated in place when a ruleset with the same name already exists. -# The JSON is the shape the GitHub UI imports and exports -# (Settings -> Rules -> Rulesets), so it round-trips through the dashboard. -# -# Idempotent: safe to re-run after editing any of the JSON files. Needs `gh` -# authenticated as a repo admin, and `jq`. -# -# The rulesets API is plan-gated for private repos (Free plan: 403 "Upgrade to -# GitHub Pro..."). The script applies the plain settings, says so, and exits 0 -# so it can sit in a setup checklist without failing it. -# -# Usage: -# scripts/setup-repo.sh # the repo behind the 'origin' remote -# scripts/setup-repo.sh OWNER/REPO # an explicit target -set -euo pipefail - -command -v jq > /dev/null || { - echo "error: jq is required (brew install jq)" >&2 - exit 1 -} -gh auth status > /dev/null 2>&1 || { - echo "error: gh is not authenticated (run: gh auth login)" >&2 - exit 1 -} - -# Resolve the target from 'origin' rather than gh's default-repo guess, which -# on a checkout with several remotes (a fork plus the org repo) can pick the -# wrong one and quietly reconfigure it. -if [[ $# -gt 0 ]]; then - repo="$1" -elif origin_url="$(git remote get-url origin 2> /dev/null)"; then - repo="$(gh repo view "$origin_url" --json nameWithOwner --jq .nameWithOwner)" -else - repo="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" -fi -echo "== GitHub settings for $repo ==" - -SETTINGS_FILE=".github/repo-settings.json" -if [[ -f "$SETTINGS_FILE" ]]; then - echo "+ repo settings from $SETTINGS_FILE" - gh api -X PATCH "repos/$repo" --input "$SETTINGS_FILE" > /dev/null -else - echo "note: $SETTINGS_FILE not found; skipping repo settings" -fi - -shopt -s nullglob -files=(.github/rulesets/*.json) -if [[ ${#files[@]} -eq 0 ]]; then - echo "no ruleset files under .github/rulesets/; done." - exit 0 -fi - -# One listing up front; each file is then a create (no ruleset with that -# name) or an in-place update (PUT by id), so re-runs never duplicate. -if ! existing="$(gh api "repos/$repo/rulesets?per_page=100" 2>&1)"; then - if grep -q "Upgrade to GitHub" <<< "$existing"; then - echo "note: the rulesets API refuses private repos on this GitHub plan" - echo " (needs Pro/Team+). Plain settings were applied; re-run this" - echo " script after a plan upgrade to apply .github/rulesets/*.json." - exit 0 - fi - printf '%s\n' "$existing" >&2 - exit 1 -fi -for f in "${files[@]}"; do - name="$(jq -r '.name' "$f")" - id="$(jq -r --arg name "$name" '.[] | select(.name == $name) | .id' <<< "$existing" | head -n 1)" - if [[ -n "$id" ]]; then - echo "+ update ruleset '$name' (id $id) from $f" - gh api -X PUT "repos/$repo/rulesets/$id" --input "$f" > /dev/null - else - echo "+ create ruleset '$name' from $f" - gh api -X POST "repos/$repo/rulesets" --input "$f" > /dev/null - fi -done - -cat << EOM - -Applied. Settings that stay manual: - - GitHub environments and their reviewers/secrets (e.g. the 'pypi' - environment used by publish.yml for trusted publishing) - - required status checks only take effect once the named check has run at - least once on the repository -EOM 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..dd36df4 --- /dev/null +++ b/template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py @@ -0,0 +1,315 @@ +#!/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..91bfe24 --- /dev/null +++ b/tests/test_setup_repo.py @@ -0,0 +1,202 @@ +"""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) From b38d6c647b9204cc197c0e88d301838e990db4c5 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 11 Sep 2026 13:36:10 +1000 Subject: [PATCH 5/6] Format setup_repo.py and its test with the repo's default ruff config The smoke-test job runs pre-commit over every tracked file with ruff's defaults (line length 88), while the scaffold formats at 100. Format so both are satisfied. Co-Authored-By: Claude Fable 5.1 --- .../setup_repo.py | 56 ++++++++++++++----- tests/test_setup_repo.py | 37 +++++++++--- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py b/template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py index dd36df4..03c5c5c 100755 --- a/template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py +++ b/template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py @@ -59,7 +59,9 @@ class GhError(RuntimeError): 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) + 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 @@ -75,7 +77,9 @@ def project(current: Json, want: Json) -> Json: 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} + 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) @@ -127,15 +131,22 @@ 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 + ["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() + return gh( + [*view_args, "--json", "nameWithOwner", "--jq", ".nameWithOwner"], None + ).strip() -def plan_settings(repo: str, gh: Runner, root: Path) -> tuple[list[SettingChange], bool]: +def plan_settings( + repo: str, gh: Runner, root: Path +) -> tuple[list[SettingChange], bool]: path = root / SETTINGS_FILE if not path.exists(): return [], False @@ -169,7 +180,9 @@ def plan_rulesets(repo: str, gh: Runner, root: Path) -> tuple[list[RulesetPlan], 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)) + current = json.loads( + gh(["api", f"repos/{repo}/rulesets/{existing_id}"], None) + ) diff = "".join( difflib.unified_diff( canonical(project(current, desired)).splitlines(keepends=True), @@ -197,13 +210,17 @@ def describe(plan: Plan, out: Callable[[str], None]) -> None: 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)}") + 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}") + 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(): @@ -216,7 +233,10 @@ 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)) + gh( + ["api", "-X", "PATCH", f"repos/{plan.repo}", "--input", "-"], + json.dumps(body), + ) for rs in plan.rulesets: if not rs.changed: continue @@ -259,11 +279,16 @@ def main( epilog="See the module docstring for details.", ) parser.add_argument( - "repo", nargs="?", metavar="OWNER/REPO", help="target repo (default: the 'origin' remote)" + "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" + "--dry-run", + action="store_true", + help="show the differences and exit without applying", ) mode.add_argument( "--yes", @@ -297,9 +322,14 @@ def main( 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.") + 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"): + if ask(f"Apply these changes to {repo}? [y/N] ").strip().lower() not in ( + "y", + "yes", + ): out("Aborted; nothing applied.") return 1 diff --git a/tests/test_setup_repo.py b/tests/test_setup_repo.py index 91bfe24..e651b9c 100644 --- a/tests/test_setup_repo.py +++ b/tests/test_setup_repo.py @@ -31,7 +31,9 @@ "target": "branch", "enforcement": "active", "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, - "bypass_actors": [{"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"}], + "bypass_actors": [ + {"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"} + ], "rules": [ {"type": "deletion"}, {"type": "pull_request", "parameters": {"required_approving_review_count": 0}}, @@ -42,7 +44,9 @@ 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"}) + 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 @@ -76,7 +80,9 @@ def __call__(self, args: Sequence[str], stdin: str | None) -> str: 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]) + 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)) @@ -92,18 +98,31 @@ def repo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return tmp_path -def run(gh: FakeGh, *argv: str, interactive: bool = False, answer: str = "n") -> tuple[int, str]: +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 + [*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"}]} + 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"}]} + 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: @@ -116,7 +135,9 @@ def test_nothing_to_apply_when_everything_matches(repo_dir: Path) -> None: assert gh.writes == [] -def test_first_run_creates_ruleset_and_patches_only_changed_keys(repo_dir: Path) -> None: +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 From 78646d10f9faba61339d2f7f3fc3c9db7a59e9e5 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Fri, 11 Sep 2026 13:36:51 +1000 Subject: [PATCH 6/6] Give the template repo a ruff.toml matching the scaffold The smoke-test job runs pre-commit over every tracked file, and without a root config ruff formatted the template's own Python at its default line length, disagreeing with the scaffold's 100. Mirror the scaffold settings so the same file formats identically in both places. Co-Authored-By: Claude Fable 5.1 --- ruff.toml | 43 +++++++++++++++++++ .../setup_repo.py | 32 ++++---------- tests/test_setup_repo.py | 16 ++----- 3 files changed, 55 insertions(+), 36 deletions(-) create mode 100644 ruff.toml 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/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py b/template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py index 03c5c5c..f57f15c 100755 --- a/template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py +++ b/template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py @@ -59,9 +59,7 @@ class GhError(RuntimeError): 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 - ) + 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 @@ -77,9 +75,7 @@ def project(current: Json, want: Json) -> Json: 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 - } + 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) @@ -139,14 +135,10 @@ def resolve_repo(explicit: str | None, gh: Runner) -> str: 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() + return gh([*view_args, "--json", "nameWithOwner", "--jq", ".nameWithOwner"], None).strip() -def plan_settings( - repo: str, gh: Runner, root: Path -) -> tuple[list[SettingChange], bool]: +def plan_settings(repo: str, gh: Runner, root: Path) -> tuple[list[SettingChange], bool]: path = root / SETTINGS_FILE if not path.exists(): return [], False @@ -180,9 +172,7 @@ def plan_rulesets(repo: str, gh: Runner, root: Path) -> tuple[list[RulesetPlan], 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) - ) + current = json.loads(gh(["api", f"repos/{repo}/rulesets/{existing_id}"], None)) diff = "".join( difflib.unified_diff( canonical(project(current, desired)).splitlines(keepends=True), @@ -210,17 +200,13 @@ def describe(plan: Plan, out: Callable[[str], None]) -> None: 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)}" - ) + 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}" - ) + 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(): @@ -322,9 +308,7 @@ def main( 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." - ) + 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", diff --git a/tests/test_setup_repo.py b/tests/test_setup_repo.py index e651b9c..35162fa 100644 --- a/tests/test_setup_repo.py +++ b/tests/test_setup_repo.py @@ -31,9 +31,7 @@ "target": "branch", "enforcement": "active", "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, - "bypass_actors": [ - {"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"} - ], + "bypass_actors": [{"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"}], "rules": [ {"type": "deletion"}, {"type": "pull_request", "parameters": {"required_approving_review_count": 0}}, @@ -44,9 +42,7 @@ 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"} - ) + 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 @@ -80,9 +76,7 @@ def __call__(self, args: Sequence[str], stdin: str | None) -> str: 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] - ) + 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)) @@ -98,9 +92,7 @@ def repo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return tmp_path -def run( - gh: FakeGh, *argv: str, interactive: bool = False, answer: str = "n" -) -> tuple[int, str]: +def run(gh: FakeGh, *argv: str, interactive: bool = False, answer: str = "n") -> tuple[int, str]: lines: list[str] = [] code = setup_repo.main( [*argv, REPO],