Last active
September 25, 2026 13:35
-
-
Save skwashd/33d004b1e178f4ff09dc745908f8816e to your computer and use it in GitHub Desktop.
Migrate from CLAUDE.md to AGENTS.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S uv run --script | |
| # /// script | |
| # requires-python = ">=3.12" | |
| # dependencies = ["httpx2>=2.8"] | |
| # /// | |
| """Migrate CLAUDE.md -> AGENTS.md across a user's or org's repos via the GitHub API. | |
| Finds candidates with the REST Git Trees API (one recursive call per repo), | |
| reads contents and commits via GraphQL. In every directory of the default branch: | |
| * CLAUDE.md is a file, AGENTS.md absent: | |
| CLAUDE.md is deleted and AGENTS.md added with identical content. | |
| * CLAUDE.md is a file, AGENTS.md a symlink to it: | |
| handled in two commits so the symlink is fully removed before the file | |
| is recreated: (1/2) delete CLAUDE.md and the AGENTS.md symlink, | |
| (2/2) add AGENTS.md as a regular file. | |
| * CLAUDE.md is a symlink to a regular AGENTS.md in the same directory: | |
| CLAUDE.md is deleted. | |
| * Anything ambiguous is skipped and reported. | |
| A repo gets one commit, or two if any directory has an AGENTS.md symlink. | |
| If the second commit can't be made, the new AGENTS.md contents are written | |
| under ./recovery/<owner>/<repo>/ so nothing is lost. | |
| With --pr, commits go on a new branch (default: agent-md) cut from the default | |
| branch, and a pull request is opened against it. The default branch is never | |
| touched. Repos where that branch already exists are skipped. | |
| Dry run by default; pass --apply to commit. | |
| Usage: | |
| ./claude-to-agents.py skwashd | |
| ./claude-to-agents.py proactiveops --prefix tflint- --prefix picofun | |
| ./claude-to-agents.py davehallconsulting --only some-repo --apply | |
| ./claude-to-agents.py skwashd --skip-dir fixtures -v | |
| ./claude-to-agents.py proactiveops --pr --apply | |
| Auth: GITHUB_TOKEN / GH_TOKEN, falling back to `gh auth token`. | |
| Needs Contents: write (fine-grained) or `repo` (classic). | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import base64 | |
| import os | |
| import posixpath | |
| import re | |
| import subprocess | |
| import sys | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| import httpx2 | |
| GRAPHQL = "https://api.github.com/graphql" | |
| GRAPHQL_HEADERS = {"Accept": "application/vnd.github.v4.idl"} # avoids 502s | |
| REST = "https://api.github.com" | |
| REST_HEADERS = { | |
| "Accept": "application/vnd.github+json", | |
| "X-GitHub-Api-Version": "2022-11-28", | |
| } | |
| SRC, DST = "CLAUDE.md", "AGENTS.md" | |
| COMMIT_MSG = "CLAUDE.md -> AGENTS.md" | |
| SPLIT_MSG_1 = "CLAUDE.md -> AGENTS.md (1/2): remove old files" | |
| SPLIT_MSG_2 = "CLAUDE.md -> AGENTS.md (2/2): add AGENTS.md" | |
| UNLINK_MSG = "Remove CLAUDE.md symlink" | |
| PR_TITLE = "CLAUDE.md -> AGENTS.md" | |
| DEFAULT_PR_BRANCH = "agent-md" | |
| RECOVERY_DIR = Path("recovery") | |
| # REST tree modes are strings. | |
| FILE_MODES = {"100644", "100755"} | |
| SYMLINK = "120000" | |
| DEFAULT_SKIP_DIRS = { | |
| "node_modules", "vendor", ".venv", "venv", ".tox", "__pycache__", | |
| ".terraform", "dist", "build", "target", | |
| } | |
| BLOB_BATCH = 50 | |
| OID_RE = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}") | |
| REPO_FIELDS = """ | |
| fragment RepoFields on Repository { | |
| id | |
| name | |
| owner { login } | |
| isArchived | |
| isFork | |
| isEmpty | |
| defaultBranchRef { | |
| name | |
| target { ... on Commit { oid tree { oid } } } | |
| } | |
| prRef: ref(qualifiedName: $prRef) { | |
| associatedPullRequests(states: OPEN, first: 1) { nodes { url } } | |
| } | |
| } | |
| """ | |
| LIST_REPOS = """ | |
| query($login: String!, $cursor: String, $prRef: String!) { | |
| repositoryOwner(login: $login) { | |
| repositories(first: 100, after: $cursor, orderBy: {field: NAME, direction: ASC}) { | |
| pageInfo { hasNextPage endCursor } | |
| nodes { ...RepoFields } | |
| } | |
| } | |
| } | |
| """ + REPO_FIELDS | |
| GET_REPO = """ | |
| query($owner: String!, $name: String!, $prRef: String!) { | |
| repository(owner: $owner, name: $name) { ...RepoFields } | |
| } | |
| """ + REPO_FIELDS | |
| HEAD_OID = """ | |
| query($owner: String!, $name: String!, $ref: String!) { | |
| repository(owner: $owner, name: $name) { ref(qualifiedName: $ref) { target { oid } } } | |
| } | |
| """ | |
| CREATE_REF = """ | |
| mutation($input: CreateRefInput!) { createRef(input: $input) { ref { id } } } | |
| """ | |
| DELETE_REF = """ | |
| mutation($input: DeleteRefInput!) { deleteRef(input: $input) { clientMutationId } } | |
| """ | |
| CREATE_PR = """ | |
| mutation($input: CreatePullRequestInput!) { | |
| createPullRequest(input: $input) { pullRequest { url } } | |
| } | |
| """ | |
| CREATE_COMMIT = """ | |
| mutation($input: CreateCommitOnBranchInput!) { | |
| createCommitOnBranch(input: $input) { commit { oid url tree { oid } } } | |
| } | |
| """ | |
| class GraphQLError(RuntimeError): | |
| pass | |
| class TreeTruncated(RuntimeError): | |
| pass | |
| @dataclass | |
| class Change: | |
| dir: str # "" for repo root | |
| kind: str # "rename" | "unlink" | |
| contents: bytes | None = None | |
| dst_symlink: bool = False # rename where AGENTS.md is currently a symlink | |
| def describe(self) -> str: | |
| if self.kind == "rename": | |
| via = " (replacing symlink)" if self.dst_symlink else "" | |
| return f"{at(self.dir, SRC)} -> {at(self.dir, DST)}{via}" | |
| return f"remove {at(self.dir, SRC)} symlink" | |
| @dataclass | |
| class RepoResult: | |
| repo: str | |
| status: str # changed | pr-opened | would-change | nothing | skip | error | |
| detail: str = "" | |
| changes: list[Change] = field(default_factory=list) | |
| skips: list[str] = field(default_factory=list) | |
| def at(d: str, f: str) -> str: | |
| return f"{d}/{f}" if d else f | |
| def link_points_to(link_dir: str, target: str, want: str) -> bool: | |
| """Does a symlink in link_dir with this target resolve to repo path `want`?""" | |
| target = target.strip() | |
| if not target or target.startswith("/"): | |
| return False | |
| return posixpath.normpath(posixpath.join(link_dir, target)) == want | |
| def get_token() -> str: | |
| for var in ("GITHUB_TOKEN", "GH_TOKEN"): | |
| if tok := os.environ.get(var): | |
| return tok | |
| try: | |
| return subprocess.run( | |
| ["gh", "auth", "token"], check=True, capture_output=True, text=True | |
| ).stdout.strip() | |
| except (FileNotFoundError, subprocess.CalledProcessError): | |
| sys.exit("No token: set GITHUB_TOKEN/GH_TOKEN or log in with `gh auth login`.") | |
| class GitHub: | |
| def __init__(self, token: str) -> None: | |
| self.http = httpx2.Client( | |
| headers={"Authorization": f"Bearer {token}"}, timeout=60.0 | |
| ) | |
| def gql(self, query: str, **variables) -> dict: | |
| resp = self.http.post( | |
| GRAPHQL, | |
| json={"query": query, "variables": variables}, | |
| headers=GRAPHQL_HEADERS, | |
| ) | |
| resp.raise_for_status() | |
| body = resp.json() | |
| if body.get("errors"): | |
| raise GraphQLError("; ".join(e.get("message", str(e)) for e in body["errors"])) | |
| return body["data"] | |
| def list_repos(self, login: str, pr_branch: str) -> list[dict]: | |
| repos, cursor = [], None | |
| while True: | |
| owner = self.gql( | |
| LIST_REPOS, login=login, cursor=cursor, prRef=f"refs/heads/{pr_branch}" | |
| )["repositoryOwner"] | |
| if owner is None: | |
| sys.exit(f"No user or org named {login!r}.") | |
| conn = owner["repositories"] | |
| # A user's repo connection can include collaborations; keep owned only. | |
| repos += [ | |
| r for r in conn["nodes"] | |
| if r["owner"]["login"].lower() == login.lower() | |
| ] | |
| if not conn["pageInfo"]["hasNextPage"]: | |
| return repos | |
| cursor = conn["pageInfo"]["endCursor"] | |
| def get_repo(self, owner: str, name: str, pr_branch: str) -> dict: | |
| """One repo by exact name, without listing everything the owner has.""" | |
| return self.gql( | |
| GET_REPO, owner=owner, name=name, prRef=f"refs/heads/{pr_branch}" | |
| )["repository"] | |
| def head_oid(self, owner: str, name: str, branch: str) -> str: | |
| repo = self.gql(HEAD_OID, owner=owner, name=name, ref=f"refs/heads/{branch}")["repository"] | |
| return repo["ref"]["target"]["oid"] | |
| def create_branch(self, repo_id: str, branch: str, oid: str) -> str: | |
| return self.gql( | |
| CREATE_REF, | |
| input={"repositoryId": repo_id, "name": f"refs/heads/{branch}", "oid": oid}, | |
| )["createRef"]["ref"]["id"] | |
| def delete_branch(self, ref_id: str) -> None: | |
| self.gql(DELETE_REF, input={"refId": ref_id}) | |
| def create_pr(self, repo_id: str, base: str, head: str, title: str, body: str) -> str: | |
| return self.gql( | |
| CREATE_PR, | |
| input={"repositoryId": repo_id, "baseRefName": base, "headRefName": head, | |
| "title": title, "body": body}, | |
| )["createPullRequest"]["pullRequest"]["url"] | |
| def tree(self, owner: str, name: str, tree_sha: str) -> list[dict]: | |
| """Whole tree in one call. Pinned to a tree SHA, so it can't drift from HEAD.""" | |
| resp = self.http.get( | |
| f"{REST}/repos/{owner}/{name}/git/trees/{tree_sha}", | |
| params={"recursive": "1"}, | |
| headers=REST_HEADERS, | |
| ) | |
| resp.raise_for_status() | |
| body = resp.json() | |
| if body.get("truncated"): | |
| raise TreeTruncated("recursive tree truncated by GitHub; repo too large to scan safely") | |
| return body["tree"] | |
| def read_blobs(self, owner: str, name: str, oids: list[str]) -> dict[str, dict]: | |
| """Fetch many blobs in one GraphQL query per batch, using aliases.""" | |
| out: dict[str, dict] = {} | |
| uniq = sorted(set(oids)) | |
| for oid in uniq: | |
| if not OID_RE.fullmatch(oid): | |
| raise ValueError(f"unexpected object id {oid!r}") | |
| for i in range(0, len(uniq), BLOB_BATCH): | |
| chunk = uniq[i : i + BLOB_BATCH] | |
| fields = "\n".join( | |
| f'b{j}: object(oid: "{oid}") ' | |
| "{ ... on Blob { text byteSize isBinary isTruncated } }" | |
| for j, oid in enumerate(chunk) | |
| ) | |
| query = ( | |
| "query($owner: String!, $name: String!) " | |
| f"{{ repository(owner: $owner, name: $name) {{ {fields} }} }}" | |
| ) | |
| data = self.gql(query, owner=owner, name=name)["repository"] | |
| for j, oid in enumerate(chunk): | |
| out[oid] = data[f"b{j}"] or {} | |
| return out | |
| def create_commit(self, owner: str, name: str, branch: str, head_oid: str, | |
| headline: str, body: str, | |
| additions: list[dict], deletions: list[dict]) -> dict: | |
| message = {"headline": headline} | |
| if body: | |
| message["body"] = body | |
| return self.gql( | |
| CREATE_COMMIT, | |
| input={ | |
| "branch": {"repositoryNameWithOwner": f"{owner}/{name}", "branchName": branch}, | |
| "expectedHeadOid": head_oid, | |
| "message": message, | |
| "fileChanges": {"additions": additions, "deletions": deletions}, | |
| }, | |
| )["createCommitOnBranch"]["commit"] | |
| def decide(d: str, src: dict | None, dst: dict | None, blobs: dict[str, dict]) -> Change | str | None: | |
| """Return a Change, a skip reason, or None if there's nothing to do in this dir.""" | |
| src_path, dst_path = at(d, SRC), at(d, DST) | |
| if src is None: | |
| return None | |
| if src["mode"] == SYMLINK: | |
| target = blobs[src["sha"]].get("text") or "" | |
| if not link_points_to(d, target, dst_path): | |
| return f"{src_path}: symlink to {target.strip()!r}, not {DST}" | |
| if dst is None or dst["mode"] not in FILE_MODES: | |
| return f"{src_path}: symlink to {DST}, but {DST} isn't a regular file" | |
| return Change(d, "unlink") | |
| if src["mode"] not in FILE_MODES: | |
| return f"{src_path}: unexpected mode {src['mode']}" | |
| blob = blobs[src["sha"]] | |
| if blob.get("isBinary") or blob.get("isTruncated") or blob.get("text") is None: | |
| return f"{src_path}: can't read as text (binary/truncated)" | |
| raw = blob["text"].encode("utf-8") | |
| if len(raw) != blob["byteSize"]: | |
| return f"{src_path}: byte size mismatch; refusing to rewrite" | |
| if dst is None: | |
| return Change(d, "rename", raw) | |
| if dst["mode"] != SYMLINK: | |
| return f"{dst_path}: already a regular file; resolve by hand" | |
| target = blobs[dst["sha"]].get("text") or "" | |
| if not link_points_to(d, target, src_path): | |
| return f"{dst_path}: symlink to {target.strip()!r}, not {SRC}" | |
| return Change(d, "rename", raw, dst_symlink=True) | |
| def plan_repo(gh: GitHub, owner: str, name: str, tree_sha: str, | |
| skip_dirs: set[str]) -> tuple[list[Change], list[str]]: | |
| by_dir: dict[str, dict[str, dict]] = {} | |
| for e in gh.tree(owner, name, tree_sha): | |
| if e["type"] != "blob": | |
| continue | |
| d, base = posixpath.split(e["path"]) | |
| if base not in (SRC, DST): | |
| continue | |
| if d and skip_dirs.intersection(d.split("/")): | |
| continue | |
| by_dir.setdefault(d, {})[base] = e | |
| # Only read what decisions depend on: CLAUDE.md always, AGENTS.md if it's a symlink. | |
| need = [] | |
| for files in by_dir.values(): | |
| if (src := files.get(SRC)) is None: | |
| continue | |
| need.append(src["sha"]) | |
| if (dst := files.get(DST)) is not None and dst["mode"] == SYMLINK: | |
| need.append(dst["sha"]) | |
| blobs = gh.read_blobs(owner, name, need) if need else {} | |
| changes, skips = [], [] | |
| for d in sorted(by_dir): | |
| outcome = decide(d, by_dir[d].get(SRC), by_dir[d].get(DST), blobs) | |
| if isinstance(outcome, Change): | |
| changes.append(outcome) | |
| elif outcome: | |
| skips.append(outcome) | |
| return changes, skips | |
| def additions_for(changes: list[Change]) -> list[dict]: | |
| return [ | |
| {"path": at(c.dir, DST), "contents": base64.b64encode(c.contents).decode()} | |
| for c in changes if c.kind == "rename" | |
| ] | |
| def body_for(changes: list[Change]) -> str: | |
| return "\n".join(f"- {c.describe()}" for c in changes) if len(changes) > 1 else "" | |
| def save_recovery(owner: str, name: str, changes: list[Change]) -> Path: | |
| root = RECOVERY_DIR / owner / name | |
| for c in changes: | |
| if c.kind == "rename": | |
| path = root / at(c.dir, DST) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_bytes(c.contents) | |
| return root | |
| def commit_changes(gh: GitHub, owner: str, name: str, branch: str, head_oid: str, | |
| changes: list[Change], recover: bool = True) -> tuple[str, list[str]]: | |
| """Make one or two commits on branch. Returns (commit urls, problems found).""" | |
| split = any(c.dst_symlink for c in changes) | |
| if not split: | |
| adds = additions_for(changes) | |
| commit = gh.create_commit( | |
| owner, name, branch, head_oid, | |
| COMMIT_MSG if adds else UNLINK_MSG, body_for(changes), | |
| additions=adds, | |
| deletions=[{"path": at(c.dir, SRC)} for c in changes], | |
| ) | |
| urls = [commit["url"]] | |
| else: | |
| # 1/2: remove every CLAUDE.md we're handling, plus the AGENTS.md symlinks. | |
| deletions = [{"path": at(c.dir, SRC)} for c in changes] | |
| deletions += [{"path": at(c.dir, DST)} for c in changes if c.dst_symlink] | |
| first = gh.create_commit( | |
| owner, name, branch, head_oid, SPLIT_MSG_1, body_for(changes), | |
| additions=[], deletions=deletions, | |
| ) | |
| # 2/2: add AGENTS.md back as regular files. Retry once against a fresh | |
| # head in case something landed between the two commits. | |
| adds = additions_for(changes) | |
| renames = [c for c in changes if c.kind == "rename"] | |
| try: | |
| commit = gh.create_commit( | |
| owner, name, branch, first["oid"], SPLIT_MSG_2, body_for(renames), | |
| additions=adds, deletions=[], | |
| ) | |
| except (GraphQLError, httpx2.HTTPError): | |
| try: | |
| commit = gh.create_commit( | |
| owner, name, branch, gh.head_oid(owner, name, branch), | |
| SPLIT_MSG_2, body_for(renames), additions=adds, deletions=[], | |
| ) | |
| except (GraphQLError, httpx2.HTTPError) as exc: | |
| missing = ", ".join(a["path"] for a in adds) | |
| if not recover: | |
| return first["url"], [ | |
| f"commit 1/2 landed on {branch} but 2/2 failed ({exc}); " | |
| f"{branch} is missing {missing}" | |
| ] | |
| saved = save_recovery(owner, name, changes) | |
| return first["url"], [ | |
| f"commit 1/2 landed but 2/2 failed ({exc}); " | |
| f"repo is missing {missing}; contents saved under {saved}/" | |
| ] | |
| urls = [first["url"], commit["url"]] | |
| # Verify the final tree: CLAUDE.md gone, AGENTS.md a regular file (not a symlink). | |
| after = {e["path"]: e["mode"] for e in gh.tree(owner, name, commit["tree"]["oid"])} | |
| problems = [] | |
| for c in changes: | |
| if at(c.dir, SRC) in after: | |
| problems.append(f"{at(c.dir, SRC)} still present") | |
| if c.kind == "rename" and after.get(at(c.dir, DST)) not in FILE_MODES: | |
| problems.append(f"{at(c.dir, DST)} mode {after.get(at(c.dir, DST), 'missing')}") | |
| return " ".join(urls), problems | |
| def pr_body(changes: list[Change], skips: list[str]) -> str: | |
| lines = [ | |
| "Claude Code now reads `AGENTS.md`, so the context file moves to the " | |
| "cross-tool name.", | |
| "", | |
| "### Changes", | |
| *(f"- {c.describe()}" for c in changes), | |
| ] | |
| if skips: | |
| lines += ["", "### Needs a human", *(f"- {s}" for s in skips)] | |
| lines += ["", "_Generated by claude-to-agents.py._"] | |
| return "\n".join(lines) | |
| def open_pr(gh: GitHub, owner: str, repo: dict, base: str, head_oid: str, | |
| pr_branch: str, changes: list[Change], skips: list[str]) -> tuple[str, list[str]]: | |
| """Cut pr_branch from base, commit there, open a PR. Returns (detail, problems).""" | |
| name = repo["name"] | |
| ref_id = gh.create_branch(repo["id"], pr_branch, head_oid) | |
| try: | |
| urls, problems = commit_changes( | |
| gh, owner, name, pr_branch, head_oid, changes, recover=False | |
| ) | |
| except (GraphQLError, httpx2.HTTPError): | |
| # Nothing landed on the branch; don't leave an empty one behind. | |
| gh.delete_branch(ref_id) | |
| raise | |
| if problems: | |
| return f"branch {pr_branch}: {urls}", problems | |
| try: | |
| return gh.create_pr(repo["id"], base, pr_branch, PR_TITLE, pr_body(changes, skips)), [] | |
| except (GraphQLError, httpx2.HTTPError) as exc: | |
| return f"branch {pr_branch}: {urls}", [f"commits pushed but PR creation failed ({exc})"] | |
| def process_repo(gh: GitHub, owner: str, repo: dict, apply: bool, | |
| skip_dirs: set[str], pr_branch: str | None) -> RepoResult: | |
| name = repo["name"] | |
| full = f"{owner}/{name}" | |
| if repo["isArchived"]: | |
| return RepoResult(full, "skip", "archived") | |
| ref = repo["defaultBranchRef"] | |
| if repo["isEmpty"] or not ref or not ref.get("target"): | |
| return RepoResult(full, "skip", "empty") | |
| branch = ref["name"] | |
| head_oid = ref["target"]["oid"] | |
| tree_sha = ref["target"]["tree"]["oid"] | |
| if pr_branch and repo.get("prRef") is not None: | |
| open_prs = repo["prRef"]["associatedPullRequests"]["nodes"] | |
| where = f" (open PR: {open_prs[0]['url']})" if open_prs else "" | |
| return RepoResult(full, "skip", f"branch {pr_branch} already exists{where}") | |
| changes, skips = plan_repo(gh, owner, name, tree_sha, skip_dirs) | |
| res = RepoResult(full, "", changes=changes, skips=skips) | |
| if not changes: | |
| res.status = "skip" if skips else "nothing" | |
| res.detail = "needs attention" if skips else "" | |
| return res | |
| if not apply: | |
| n = 2 if any(c.dst_symlink for c in changes) else 1 | |
| where = f"PR {pr_branch} -> {branch}" if pr_branch else branch | |
| res.status = "would-change" | |
| res.detail = f"{where} @ {head_oid[:7]}, {n} commit{'s' if n > 1 else ''}" | |
| return res | |
| if pr_branch: | |
| detail, problems = open_pr(gh, owner, repo, branch, head_oid, pr_branch, changes, skips) | |
| if problems: | |
| res.status, res.detail = "error", f"{detail} but: " + "; ".join(problems) | |
| else: | |
| res.status, res.detail = "pr-opened", detail | |
| return res | |
| urls, problems = commit_changes(gh, owner, name, branch, head_oid, changes) | |
| if problems: | |
| res.status, res.detail = "error", f"committed {urls} but: " + "; ".join(problems) | |
| else: | |
| res.status, res.detail = "changed", urls | |
| return res | |
| def main() -> int: | |
| p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) | |
| p.add_argument("owner", help="GitHub user or org login") | |
| p.add_argument("--prefix", action="append", default=[], | |
| help="only repos whose name starts with this (repeatable)") | |
| p.add_argument("--only", action="append", default=[], | |
| help="only this exact repo name (repeatable)") | |
| p.add_argument("--skip-dir", action="append", default=[], | |
| help=f"extra directory name to ignore (defaults: {', '.join(sorted(DEFAULT_SKIP_DIRS))})") | |
| p.add_argument("--include-forks", action="store_true") | |
| p.add_argument("--apply", action="store_true", help="actually commit (default: dry run)") | |
| p.add_argument("--pr", action="store_true", | |
| help="commit on a new branch and open a PR instead of committing to the default branch") | |
| p.add_argument("--pr-branch", default=DEFAULT_PR_BRANCH, | |
| help=f"branch name for --pr (default: {DEFAULT_PR_BRANCH})") | |
| p.add_argument("-v", "--verbose", action="store_true", | |
| help="also list repos with nothing to do") | |
| args = p.parse_args() | |
| skip_dirs = DEFAULT_SKIP_DIRS | set(args.skip_dir) | |
| gh = GitHub(get_token()) | |
| owner = args.owner | |
| pr_branch = args.pr_branch if args.pr else None | |
| def wanted(r: dict) -> bool: | |
| if r["isFork"] and not args.include_forks: | |
| return False | |
| if args.only and r["name"] not in args.only: | |
| return False | |
| if args.prefix and not any(r["name"].startswith(x) for x in args.prefix): | |
| return False | |
| return True | |
| mode = ("APPLY" if args.apply else "DRY RUN") + (f", PR via {pr_branch}" if pr_branch else "") | |
| counts: dict[str, int] = {} | |
| if args.only: | |
| # Exact names: fetch each repo directly instead of paging through them all. | |
| repos, lookup_errors = [], [] | |
| for name in dict.fromkeys(args.only): | |
| try: | |
| r = gh.get_repo(owner, name, args.pr_branch) | |
| except (GraphQLError, httpx2.HTTPError) as exc: | |
| lookup_errors.append(RepoResult(f"{owner}/{name}", "error", str(exc))) | |
| continue | |
| moved_to = f"{r['owner']['login']}/{r['name']}" | |
| if moved_to.lower() != f"{owner}/{name}".lower(): | |
| # GitHub follows rename/transfer redirects; make the user ask for it by its real name. | |
| lookup_errors.append(RepoResult(f"{owner}/{name}", "skip", f"moved to {moved_to}")) | |
| continue | |
| repos.append(r) | |
| selected = [r for r in repos if wanted(r)] | |
| print(f"[{mode}] {len(selected)} of {len(args.only)} named repos in {owner}\n") | |
| for res in lookup_errors: | |
| counts[res.status] = counts.get(res.status, 0) + 1 | |
| print(f"{res.status:>13} {res.repo} {res.detail}") | |
| else: | |
| repos = gh.list_repos(owner, args.pr_branch) | |
| selected = [r for r in repos if wanted(r)] | |
| print(f"[{mode}] {len(selected)} of {len(repos)} repos in {owner}\n") | |
| for repo in selected: | |
| try: | |
| res = process_repo(gh, owner, repo, args.apply, skip_dirs, pr_branch) | |
| except (GraphQLError, TreeTruncated, ValueError, httpx2.HTTPError) as exc: | |
| res = RepoResult(f"{owner}/{repo['name']}", "error", str(exc)) | |
| counts[res.status] = counts.get(res.status, 0) + 1 | |
| if res.status == "nothing" and not args.verbose: | |
| continue | |
| print(f"{res.status:>13} {res.repo} {res.detail}") | |
| for c in res.changes: | |
| print(f"{'':>15}{c.kind:<7} {c.describe()}") | |
| for s in res.skips: | |
| print(f"{'':>15}{'skip':<7} {s}") | |
| print("\n" + ", ".join(f"{k}: {v}" for k, v in sorted(counts.items()))) | |
| return 1 if counts.get("error") else 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment