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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 62 additions & 30 deletions .github/workflows/repo_settings.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Repo Settings

# Turns on "Automatically delete head branches" across the organism's
# development repos, and reports any repo where it is off.
# Turns on "Automatically delete head branches" across the organism's repos,
# and reports any repo where it is off.
#
# WHY THIS EXISTS. Branch cleanup was written as a close-out step: /prm deletes
# the head once the PR merges. That step cannot run in a cloud Claude session —
Expand All @@ -14,14 +14,23 @@ name: Repo Settings
# workflow is the fix: with the setting on, GitHub removes each head at merge
# and no session credential is involved at all. It is a *per-repo* setting with
# no org-wide default, so something has to visit each repo — that something is
# here, driven off the body map, rather than a browser tab per repo.
# here rather than a browser tab per repo.
#
# WHY IT ENUMERATES THE ORG RATHER THAN THE BODY MAP. It derived its targets
# from the body map first, and that was blind in exactly the wrong place: a
# repo is created *before* anyone registers it, so the repos most in need of
# the setting were the ones the sweep could not see — along with any repo whose
# category the body map excludes for the branch sweep's sake, a boundary drawn
# around *deleting refs* that has no bearing on flipping a boolean. Listing the
# organisation instead means a new repo is covered the run after it exists, and
# no category question arises. `--outside-owner` supplies the complement: the
# body-map repos under some other account, which an org listing cannot return.
#
# WHY THE SCHEDULE APPLIES RATHER THAN AUDITS. Unlike the branch sweep, this
# deletes nothing: it flips a boolean whose effect starts at the *next* merge,
# and it is one click to undo. Left as audit-only it would report the same
# drift every week and rely on someone acting on it — which is the failure this
# is meant to end. A repo added to the body map after today is corrected on the
# next run, the same way dashboard_refresh.yml self-heals a stale page.
# is meant to end.

on:
workflow_dispatch:
Expand All @@ -47,12 +56,12 @@ jobs:
settings:
runs-on: ubuntu-latest
steps:
- name: Check out PyAutoBrain (the target derivation lives here)
- name: Check out PyAutoBrain (the complement derivation lives here)
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Check out the Mind (the body map the targets derive from)
- name: Check out the Mind (the body map the complement derives from)
uses: actions/checkout@v4
with:
repository: ${{ github.repository_owner }}/PyAutoMind
Expand All @@ -69,71 +78,94 @@ jobs:
# A repo's own GITHUB_TOKEN reaches only that repo, and this setting
# needs admin on each one. Same credential the org-wide sweep uses.
GH_TOKEN: ${{ secrets.PAT_PYAUTOLABS }}
# Passed as env rather than interpolated into the script below, so a
# value containing shell metacharacters cannot become shell.
ORG: ${{ github.repository_owner }}
MODE: ${{ inputs.mode }}
run: |
set -uo pipefail
mode='${{ inputs.mode }}'
mode="${mode:-apply}"
mode="${MODE:-apply}"

if [ -z "${GH_TOKEN:-}" ]; then
echo "::error::PAT_PYAUTOLABS is not set — cannot reach sibling repos."
exit 1
fi

# Every development repo, the self-sweeping organs included: the
# setting is per-repo and they merge pull requests like anything else.
targets=$(python3 bin/branch_sweep_targets.py --include-self-sweeping .mind/repos.yaml)
if [ -z "$targets" ]; then
echo "::error::no repos derived from the body map — refusing to continue."
# Every repo in the organisation, archived ones excluded: an archived
# repo takes no merges, and PATCHing one fails.
in_org=$(gh api --paginate "orgs/$ORG/repos?per_page=100" \
--jq '.[] | select(.archived == false) | .full_name')
if [ -z "$in_org" ]; then
# Distinguishable from "nothing to do": an empty listing means the
# PAT lost org read, and sweeping nothing would look like success.
echo "::error::listed no repos in $ORG — refusing to continue."
exit 1
fi

# The complement: body-map repos an org listing cannot return.
outside=$(python3 bin/branch_sweep_targets.py --outside-owner "$ORG" .mind/repos.yaml)

{
echo "## Automatically delete head branches — \`$mode\`"
echo
echo "| Repo | Before | Action |"
echo "|---|---|---|"
echo "| Repo | Scope | Before | Action |"
echo "|---|---|---|---|"
} >> "$GITHUB_STEP_SUMMARY"

failed=0 changed=0 already=0
for slug in $targets; do

visit() {
slug="$1" scope="$2"
# Out-of-org repos sit under a personal account. The org PAT's
# admin scope there is unverified by design, so a refusal is
# expected and must not redden a weekly unattended run; in-org, the
# same refusal is a real finding.
case "$scope" in
in-org) hard=1 ;;
*) hard=0 ;;
esac

before=$(gh api "repos/$slug" --jq '.delete_branch_on_merge' 2>/dev/null)
case "$before" in
true)
already=$((already + 1))
echo "| \`$slug\` | on | — |" >> "$GITHUB_STEP_SUMMARY"
continue ;;
echo "| \`$slug\` | $scope | on | — |" >> "$GITHUB_STEP_SUMMARY"
return ;;
false) ;;
*)
echo "::warning::$slug — could not read the setting (repo missing, or the PAT cannot see it)"
echo "| \`$slug\` | ? | **unreadable** |" >> "$GITHUB_STEP_SUMMARY"
failed=1
continue ;;
echo "| \`$slug\` | $scope | ? | **unreadable** |" >> "$GITHUB_STEP_SUMMARY"
[ "$hard" -eq 1 ] && failed=1
return ;;
esac

if [ "$mode" = "audit" ]; then
echo "| \`$slug\` | off | would enable |" >> "$GITHUB_STEP_SUMMARY"
echo "| \`$slug\` | $scope | off | would enable |" >> "$GITHUB_STEP_SUMMARY"
changed=$((changed + 1))
continue
return
fi

# -F, not -f: the API wants a JSON boolean, not the string "true".
if gh api -X PATCH "repos/$slug" -F delete_branch_on_merge=true --silent 2>/dev/null; then
echo "✓ $slug — enabled"
echo "| \`$slug\` | off | **enabled** |" >> "$GITHUB_STEP_SUMMARY"
echo "| \`$slug\` | $scope | off | **enabled** |" >> "$GITHUB_STEP_SUMMARY"
changed=$((changed + 1))
else
# The read succeeded, so this is a permission ceiling, not a typo:
# changing a repo setting needs admin, which read access does not
# imply. Widening the PAT is a human decision, not a retry.
# imply. Widening a PAT is a human decision, not a retry.
echo "::warning::$slug — PATCH refused; the PAT needs admin on this repo"
echo "| \`$slug\` | off | **refused (needs admin)** |" >> "$GITHUB_STEP_SUMMARY"
failed=1
echo "| \`$slug\` | $scope | off | **refused (needs admin)** |" >> "$GITHUB_STEP_SUMMARY"
[ "$hard" -eq 1 ] && failed=1
fi
done
}

for slug in $in_org; do visit "$slug" in-org; done
for slug in $outside; do visit "$slug" outside; done

{
echo
echo "$already already on · $changed $([ "$mode" = audit ] && echo 'to enable' || echo 'enabled')"
} >> "$GITHUB_STEP_SUMMARY"

[ "$failed" -eq 0 ] || { echo "::error::one or more repos could not be read or set — see the table"; exit 1; }
[ "$failed" -eq 0 ] || { echo "::error::one or more repos in $ORG could not be read or set — see the table"; exit 1; }
71 changes: 50 additions & 21 deletions bin/branch_sweep_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,27 @@
credentials. "Mind" and "Brain" are organism roles present in any fork, so
naming them here is not an instance fact.

The self-sweeping exclusion is the *sweep's* boundary, not the body map's, so
a second consumer can ask for it back. `--include-self-sweeping` yields every
development repo including the Mind and the Brain — what the repo-settings
sweep wants, because "delete the head branch on merge" is a per-repo setting
that has to be on in those two as much as anywhere else, and no second sweeper
collides over a settings PATCH.
## The second consumer reads the body map for its *complement*

`repo_settings.yml` turns on "delete the head branch on merge". It used to take
a widened version of the set above, and that was wrong in a way the category
boundary cannot fix: a repo is created *before* anyone registers it in the body
map, so a body-map-derived sweep is structurally blind to exactly the repos
that most need the setting. It now enumerates the organisation from the GitHub
API instead, where a new repo appears the moment it exists.

What that enumeration *cannot* see is a body-map repo under some other owner.
`--outside-owner <owner>` yields those, and only those — the complement of the
org listing. It applies **no category filter**, deliberately: the org
enumeration has none either, so filtering the complement would be a boundary
that exists on one side of the union and not the other. (In practice the
categories that end up outside an organisation's own account are the personal
ones, which the category set above excludes — so filtering here would yield
nothing at all and silently drop the repos this flag exists to find.)

Usage:
branch_sweep_targets.py <path-to-repos.yaml> # one owner/repo per line
branch_sweep_targets.py --include-self-sweeping <path-to-repos.yaml>
branch_sweep_targets.py --outside-owner <owner> <path-to-repos.yaml>
"""

from __future__ import annotations
Expand All @@ -61,32 +72,48 @@
SELF_SWEEPING_ORGANS = frozenset({"Mind", "Brain"})


def targets(body_map: dict, include_self_sweeping: bool = False) -> list[str]:
"""The development `owner/repo` slugs, in body-map order.

`include_self_sweeping` keeps the organs that host their own branch
sweeper. Only the branch sweep needs them dropped (two sweepers on one
repo would contend); a consumer that flips a repo setting does not.
"""
def targets(body_map: dict) -> list[str]:
"""The development `owner/repo` slugs, in body-map order."""
out = []
for entry in body_map["repos"].values():
if entry.get("category") not in SWEEPABLE_CATEGORIES:
continue
if not include_self_sweeping and entry.get("organ") in SELF_SWEEPING_ORGANS:
if entry.get("organ") in SELF_SWEEPING_ORGANS:
continue
out.append(entry["github"])
return out


def outside_owner(body_map: dict, owner: str) -> list[str]:
"""Body-map slugs whose owner is not `owner`, in body-map order.

The complement of a `GET /orgs/<owner>/repos` listing: what a settings
sweep would miss if it trusted the API enumeration alone. No category
filter — see the module docstring for why that asymmetry is deliberate.
"""
return [
entry["github"]
for entry in body_map["repos"].values()
if entry["github"].split("/", 1)[0] != owner
]


def main(argv: list[str]) -> int:
args = argv[1:]
include_self_sweeping = False
if "--include-self-sweeping" in args:
include_self_sweeping = True
args = [a for a in args if a != "--include-self-sweeping"]
owner = None
if "--outside-owner" in args:
i = args.index("--outside-owner")
# The flag needs a value, and the value must not be the body-map path
# arriving by accident — a missing value would otherwise swallow it and
# report "no repos outside <path>", which reads as a clean empty run.
if i + 1 >= len(args) or args[i + 1].startswith("-"):
print("branch_sweep_targets: --outside-owner needs an owner", file=sys.stderr)
return 2
owner = args[i + 1]
args = args[:i] + args[i + 2 :]
if len(args) != 1 or args[0].startswith("-"):
print(
f"usage: {Path(argv[0]).name} [--include-self-sweeping] <path-to-repos.yaml>",
f"usage: {Path(argv[0]).name} [--outside-owner <owner>] <path-to-repos.yaml>",
file=sys.stderr,
)
return 2
Expand All @@ -96,7 +123,9 @@ def main(argv: list[str]) -> int:
if not path.is_file():
print(f"branch_sweep_targets: no body map at {path}", file=sys.stderr)
return 1
for slug in targets(yaml.safe_load(path.read_text()), include_self_sweeping):
body_map = yaml.safe_load(path.read_text())
slugs = outside_owner(body_map, owner) if owner is not None else targets(body_map)
for slug in slugs:
print(slug)
return 0

Expand Down
2 changes: 1 addition & 1 deletion skills/repo_cleanup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,6 @@ Format: [`reference.md`](reference.md) → "Recap".
- A repo that still accumulates merged PR heads has **"Automatically delete head
branches" off** (Settings → General). That setting prevents the backlog this
skill exists to clear, and no sweep substitutes for it — dispatch
`repo_settings.yml` (mode `apply`) to turn it on body-map-wide, then move on.
`repo_settings.yml` (mode `apply`) to turn it on org-wide, then move on.
- Cloud-session routing (dispatch `branch_sweep.yml` rather than pushing
deletes) is in [`reference.md`](reference.md) → "Execution environments".
7 changes: 7 additions & 0 deletions skills/repo_cleanup/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ Never-touched repos fall out with their categories — `autolens_assistant` is a
names them twice. Brain code may not name satellite repos at all (the tenant
firewall enforces this; a hardcoded list was written first and rejected).

That boundary is this sweep's alone. `repo_settings.yml` no longer shares it:
flipping `delete_branch_on_merge` is not deleting a ref, and a body-map-derived
target set could never see a repo in the window between its creation and its
registration — so it lists the organisation from the GitHub API instead, and
uses `--outside-owner` only for the body-map repos an org listing cannot
return.

## Branches that must not simply be deleted

A branch the sweep classes as *unmerged* is not automatically live work. It may
Expand Down
Loading
Loading