From 9166f84f8a12be281ba0c9fbb81aae67425e5167 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 20:19:34 +0000 Subject: [PATCH 1/3] scaffold-project.py, adopt-project.py and setup-project.py: the plan asks GitHub's limits on a name, a topic and an organisation where GitHub is, so an id over 39 characters no longer fails `--add-topic` after the repositories exist (#838) GitHub keeps a repository's name to 100 characters, a topic to 50 and a user's or an organisation's name to 39. These are GitHub's documented limits; no run here measured them (none creates a real repository). Nothing asked them. The scaffold sets `xf-project-` last, so an id over 39 characters, which a `--project` of 40 or more gives by default, failed `gh repo edit --add-topic` with all three repositories created and seeded, and `adopt-project.py execute` failed there after both legs and the split were pushed. A `--project` of 96 to 246 characters, or an `--org` of 40 to 255, reached the first `gh repo create`. shape_materialize.py: `refuse_unstorable_names`, the helper that asks the disk's limit, takes the names GitHub will be asked to create and refuses one over 100 under the same code, its remediation naming the id's bound too; `refuse_a_topic_github_cannot_set` and `refuse_an_owner_github_cannot_have` ask the topic and the organisation. The numbers live there, in the plans, so nothing pinned is re-pinned. scaffold-project.py asks all three in its plan, before any process is started and under `--dry-run` alike, without `--local-remote-dir` only: `scaffold-name-too-long`, `scaffold-org-too-long` and `scaffold-topic-too-long`, a derived id named as derived and mended with `--id`. adopt-project.py `execute` asks them before either leg exists: `adopt-name-too-long` (the two legs), `adopt-org-too-long` and `adopt-topic-too-long`. setup-project.py hands the three values to the scaffold's plan at step (5), which refuses them there; its `--help` says the three numbers, as the two tools' `--help` does, and docs/cli.md is regenerated. tests/test_github_name_limits.py holds each bound at N and N+1, on each way in, local and not; tests/test_scaffold_manifest_values.py's GitHub half of the longest-name test now asks GitHub's longest, 95 with a short `--id`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 79 ++- docs/cli.md | 44 +- scaffold-project.py | 86 ++- scripts/shape_materialize.py | 138 ++++- setup-project.py | 10 +- tests/test_github_name_limits.py | 734 +++++++++++++++++++++++++ tests/test_scaffold_manifest_values.py | 8 +- 7 files changed, 1058 insertions(+), 41 deletions(-) create mode 100644 tests/test_github_name_limits.py diff --git a/adopt-project.py b/adopt-project.py index 1322b537..fdb52c1c 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -127,7 +127,13 @@ other than `private`, `public` or `internal` -- asking the validator itself, loaded from the template it ships, and not a copy of its rules. A name that cannot be a directory's (a `--project` that makes a repository name of 256 -bytes with its `.git`) is refused the same way. +bytes with its `.git`) is refused the same way. Without `--local-remote-dir` +`execute` also refuses, before either leg exists, what GitHub will not take +(#838): a leg's name over GitHub's 100 characters (`adopt-name-too-long`), an +`org:` over the 39 it keeps an organisation's name to (`adopt-org-too-long`), +and an id whose `xf-project-` topic is over its 50 +(`adopt-topic-too-long`), which `gh repo edit --add-topic` refused only once +both legs and the split were pushed. WHY `git filter-repo` AND NOT A VENDORED COPY. Extracting history correctly is a solved problem with one correct implementation, and a vendored copy of it @@ -244,6 +250,13 @@ MANIFEST_UNREADABLE_REMEDIATION, manifest_judged, manifest_value_problems, refuse_manifest_values, refuse_unstorable_names, ) +# What GitHub keeps a repository's name, a topic and an organisation's name +# to, asked by `execute` before either leg exists where GitHub is (#838). +from shape_materialize import ( # noqa: E402 + GITHUB_ID_MAX, GITHUB_NAME_MAX, GITHUB_OWNER_MAX, GITHUB_PROJECT_MAX, + GITHUB_TOPIC_MAX, refuse_a_topic_github_cannot_set, + refuse_an_owner_github_cannot_have, +) # Whether a value this tool writes into a file is text a file can hold # (#255), asked beside `one_printable_line`, the trailer's question. from shape_materialize import unwritable_reason # noqa: E402 @@ -10713,14 +10726,51 @@ def _repository_urls(args, plan: Plan, names: dict, # under `--local-remote-dir` a bare `.git` for the two new legs. A # name of 247 to 250 characters passed every check and died in # `Path.exists` with `File name too long`, a traceback, before any leg - # existed (#261). + # existed (#261). Without it the two legs are GitHub repositories, which + # GitHub keeps to 100 characters, under an organisation it keeps to 39: + # either passed to the first `gh repo create` (#838). + legs = [names["spec"], names["code"]] refuse_unstorable_names( - "adopt-name-too-long", list(names.values()), - [names["spec"], names["code"]] if local else []) + "adopt-name-too-long", list(names.values()), legs if local else [], + [] if local else legs) + if not local: + refuse_an_owner_github_cannot_have( + "adopt-org-too-long", org, f"{plan.path}: `org:`", + ORG_REMEDIATION) return (local, repositories, _remote_urls(org, names, args.local_remote_dir, source)) +#: How `adopt-org-too-long` and `adopt-topic-too-long` are told to go on +#: (#838): the plan's own key, or `plan` again with the flag that writes it. +#: Neither is a leg: `execute` refuses both before it makes one. +ORG_REMEDIATION = ( + "Remediation: no repository was created. Fix `org:` in the plan, or " + "re-run `plan` with --org, as GitHub spells the organisation, the " + "`` of `https://github.com/`: GitHub gives no user or " + f"organisation a name of more than {GITHUB_OWNER_MAX} characters.") +TOPIC_REMEDIATION = ( + "Remediation: no repository was created. Fix `id:` in the plan, or " + f"re-run `plan` with an explicit --id, of at most {GITHUB_ID_MAX} " + "characters: the id need not be the project's name lowercased, and the " + "name, which the assembly root already has, stays as it is.") + + +def _refuse_a_topic_github_cannot_set(plan: Plan, topic: str) -> None: + """`adopt-topic-too-long`: an id whose `xf-project-` topic GitHub + will not set (#838), refused before `_confirm` and before either leg + exists. `_set_the_topic` sets it on all three only once both legs and + the split are pushed, so an id over 39 characters, which a project name + of 40 or more gives by default, ended the run there, exit 2, with the + adoption pushed and the topic unset. Asked in GitHub mode alone: under + `--local-remote-dir` no topic is set. The id is named by where it came + from, `id:` or `DERIVED_ID`, as `_id_field` names it.""" + named = "`id:`" if plan.get("id") is not None else DERIVED_ID + refuse_a_topic_github_cannot_set( + "adopt-topic-too-long", topic, plan.project_id, + f"{plan.path}: {named}", TOPIC_REMEDIATION) + + def _remote_urls(org: str, names: dict, local_remote_dir: Path | None, source: Source) -> dict: """The url of each of the three repositories, by role: what `execute` @@ -11575,6 +11625,8 @@ def cmd_execute(args) -> int: for line in plan.seeding_record_disagreements(seeded): print(line) topic = naming.topic_for(plan.project_id) + if not local: + _refuse_a_topic_github_cannot_set(plan, topic) print(_topics_line(topic, local)) _confirm(args, plan, names) @@ -13253,7 +13305,11 @@ def main(argv: list[str] | None = None) -> int: "the directory, or the url for the repository.") plan.add_argument("--project", required=True, help="the assembly-root name; for an in-place adoption " - "it is the source repository's own name") + "it is the source repository's own name. " + f"`execute` refuses one over {GITHUB_PROJECT_MAX} " + "characters without --local-remote-dir: GitHub " + "keeps a leg's name, `-spec`, to " + f"{GITHUB_NAME_MAX}.") plan.add_argument("--org", default=None, help="the organisation the source repository is in, " "which the plan records as `org:` (default: the " @@ -13261,8 +13317,17 @@ def main(argv: list[str] | None = None) -> int: "one a local source's `origin` url names). A " "source whose `origin` is a path, or that has " "none, names no organisation: `plan` refuses " - "it as `adopt-no-org` unless --org is given.") - plan.add_argument("--id", default=None) + "it as `adopt-no-org` unless --org is given. " + f"`execute` refuses one over {GITHUB_OWNER_MAX} " + "characters without --local-remote-dir, GitHub's " + "limit on an organisation's name.") + plan.add_argument("--id", default=None, + help="lowercase project id (default: --project " + f"lowercased). `execute` refuses one over " + f"{GITHUB_ID_MAX} characters without " + "--local-remote-dir: GitHub keeps a topic to " + f"{GITHUB_TOPIC_MAX}, and the topic is " + "`xf-project-`.") plan.add_argument("--visibility", choices=VISIBILITY_CHOICES, default="private") plan.add_argument("--elected-by", default=None, diff --git a/docs/cli.md b/docs/cli.md index ad2cfa9b..5f1d1941 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -68,8 +68,12 @@ usage: setup-project.py [] [options] [-- ] the same thing as --project; give one or the other. --project the assembly-root name: ONE CamelCase token, no hyphen, underscore, dot or space. Prompted for when - omitted and a terminal is attached. + omitted and a terminal is attached. At most 95 + characters without --local-remote-dir: GitHub keeps + a repository's name to 100, and -spec adds 5. --id lowercase project id (default: project lowercased) + At most 39 characters without --local-remote-dir: + GitHub keeps the topic xf-project- to 50. --name "" display name (default: the project name) --visibility private|public|internal (default: private) --elected-by "" who is electing the shape (default: your gh login) @@ -89,7 +93,9 @@ usage: setup-project.py [] [options] [-- ] --org override the detected organisation. REQUIRED when run outside a checkout of openRepoShape (self-bootstrap mode): there is no origin to read it - from. + from. At most 39 characters without + --local-remote-dir, GitHub's limit on an + organisation's name. --allow-upstream-org permit `--org opensoft` itself: opensoft is the upstream owner of openRepoShape, and almost never what you meant to scaffold into. @@ -203,8 +209,12 @@ usage: setup-project.py [] [options] [-- ] the same thing as --project; give one or the other. --project the assembly-root name: ONE CamelCase token, no hyphen, underscore, dot or space. Prompted for when - omitted and a terminal is attached. + omitted and a terminal is attached. At most 95 + characters without --local-remote-dir: GitHub keeps + a repository's name to 100, and -spec adds 5. --id lowercase project id (default: project lowercased) + At most 39 characters without --local-remote-dir: + GitHub keeps the topic xf-project- to 50. --name "" display name (default: the project name) --visibility private|public|internal (default: private) --elected-by "" who is electing the shape (default: your gh login) @@ -224,7 +234,9 @@ usage: setup-project.py [] [options] [-- ] --org override the detected organisation. REQUIRED when run outside a checkout of openRepoShape (self-bootstrap mode): there is no origin to read it - from. + from. At most 39 characters without + --local-remote-dir, GitHub's limit on an + organisation's name. --allow-upstream-org permit `--org opensoft` itself: opensoft is the upstream owner of openRepoShape, and almost never what you meant to scaffold into. @@ -274,9 +286,15 @@ Create a project's three repositories in the shape this standard describes. options: -h, --help show this help message and exit - --org ORG - --project PROJECT the assembly-root name, e.g. Atlas - --id ID lowercase project id (default: --project lowercased) + --org ORG the organisation the three repositories are created + in; at most 39 characters without --local-remote-dir, + GitHub's limit on an organisation's name + --project PROJECT the assembly-root name, e.g. Atlas; at most 95 + characters without --local-remote-dir, as GitHub keeps + a repository's name to 100 and `-spec` adds 5 + --id ID lowercase project id (default: --project lowercased); + at most 39 characters without --local-remote-dir, as + GitHub keeps the topic `xf-project-` to 50 --name NAME display name --visibility {private,public,internal} default: private for a fresh scaffold; with --reuse- @@ -421,14 +439,22 @@ options: `acme/Atlas`: pass `./acme/Atlas` for the directory, or the url for the repository. --project PROJECT the assembly-root name; for an in-place adoption it is - the source repository's own name + the source repository's own name. `execute` refuses + one over 95 characters without --local-remote-dir: + GitHub keeps a leg's name, `-spec`, to 100. --org ORG the organisation the source repository is in, which the plan records as `org:` (default: the organisation of an `org/repo` --source, or the one a local source's `origin` url names). A source whose `origin` is a path, or that has none, names no organisation: `plan` refuses it as `adopt-no-org` unless --org is given. - --id ID + `execute` refuses one over 39 characters without + --local-remote-dir, GitHub's limit on an + organisation's name. + --id ID lowercase project id (default: --project lowercased). + `execute` refuses one over 39 characters without + --local-remote-dir: GitHub keeps a topic to 50, and + the topic is `xf-project-`. --visibility {private,public,internal} --elected-by ELECTED_BY the human electing the shape (default: `git config diff --git a/scaffold-project.py b/scaffold-project.py index df977fd5..a4302d40 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -48,7 +48,11 @@ `--name`, an `--org` it cannot hold) is refused as `scaffold-manifest-invalid`, in the validator's own words; an `--elected-on` is read in the validator's form only, on every Python. A name that cannot be a -directory's is refused as `scaffold-name-too-long`. +directory's is refused as `scaffold-name-too-long`, and so is one GitHub will +not give a repository; an `--org` longer than GitHub keeps one is +`scaffold-org-too-long`, and an id whose topic GitHub will not set is +`scaffold-topic-too-long` (#838). GitHub's three limits are asked only +without `--local-remote-dir`, by the runs that make repositories on GitHub. IDEMPOTENCY. It refuses to write into a remote or a working directory that already exists. There is no --force: re-running over a live project is not a @@ -108,6 +112,13 @@ from shape_materialize import ( # noqa: E402 refuse_manifest_values, refuse_unstorable_names, ) +# And what GitHub keeps a repository's name, a topic and an organisation's +# name to, asked before anything is created where GitHub is (#838). +from shape_materialize import ( # noqa: E402 + GITHUB_ID_MAX, GITHUB_NAME_MAX, GITHUB_OWNER_MAX, GITHUB_PROJECT_MAX, + GITHUB_TOPIC_MAX, refuse_a_topic_github_cannot_set, + refuse_an_owner_github_cannot_have, +) from shape_advisory import scaffold_lines # noqa: E402 # Whether `--elected-by`, `--reference` and `--name` are text a file can # hold: the question `adopt-project.py` asks of the same values (#282). @@ -268,12 +279,25 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--org", required=True) + # GITHUB'S LIMITS, said where each value is typed (#838), and asked + # only without `--local-remote-dir`, as the plan asks them. + parser.add_argument("--org", required=True, + help="the organisation the three repositories are " + f"created in; at most {GITHUB_OWNER_MAX} " + "characters without --local-remote-dir, " + "GitHub's limit on an organisation's name") parser.add_argument("--project", required=True, - help="the assembly-root name, e.g. Atlas") + help="the assembly-root name, e.g. Atlas; at most " + f"{GITHUB_PROJECT_MAX} characters without " + "--local-remote-dir, as GitHub keeps a " + f"repository's name to {GITHUB_NAME_MAX} and " + "`-spec` adds 5") parser.add_argument("--id", default=None, help="lowercase project id (default: --project " - "lowercased)") + f"lowercased); at most {GITHUB_ID_MAX} " + "characters without --local-remote-dir, as " + "GitHub keeps the topic `xf-project-` to " + f"{GITHUB_TOPIC_MAX}") parser.add_argument("--name", default=None, help="display name") parser.add_argument("--visibility", choices=VISIBILITY_CHOICES, default=None, @@ -825,6 +849,52 @@ def _refuse_bad_id(project_id: str, given: str | None) -> None: DERIVED_ID_REMEDIATION) +#: How `scaffold-topic-too-long` and `scaffold-org-too-long` are told to go +#: on (#838). A `--id` the person passed is theirs to shorten. One +#: `_identity` derived is `--project` lowercased, and an explicit `--id` is +#: the way round it that keeps the three repositories' names as they are. +NOTHING_MADE = ("Remediation: nothing was created, and `--dry-run` refuses " + "the same.") +PASSED_TOPIC_REMEDIATION = ( + f"{NOTHING_MADE} Pass a shorter --id, of at most {GITHUB_ID_MAX} " + "characters.") +DERIVED_TOPIC_REMEDIATION = ( + f"{NOTHING_MADE} Pass an explicit --id of at most {GITHUB_ID_MAX} " + f"characters: the id need not be {ID_DERIVED_FROM}, and --project, " + "which names the three repositories, stays as it is.") +ORG_REMEDIATION = ( + f"{NOTHING_MADE} Pass --org as GitHub spells the organisation, the " + "`` of `https://github.com/`: GitHub gives no user or " + f"organisation a name of more than {GITHUB_OWNER_MAX} characters.") + + +def _refuse_what_github_cannot_hold(args, project_id: str, policy) -> None: + """`scaffold-org-too-long` and `scaffold-topic-too-long`: an `--org` + GitHub gives no organisation, and an id whose `xf-project-` topic + GitHub will not set (#838), refused where GitHub is asked, before any + process is started and under `--dry-run` alike. + + THE TOPIC IS SET LAST (`_set_topics`), after `_create_remotes`, + `_seed_legs` and `_build_assembly_root`, so an id over 39 characters, + which a `--project` of 40 or more gives by default, failed there with + all three repositories created and seeded. Under `--local-remote-dir` + neither is asked: a bare repository on disk has no topic and no owner. + The id is named as `_refuse_bad_id` names it, by where it came from.""" + if args.local_remote_dir is not None: + return + refuse_an_owner_github_cannot_have("scaffold-org-too-long", args.org, + "--org", ORG_REMEDIATION) + if args.id: + named, remediation = "--id", PASSED_TOPIC_REMEDIATION + else: + unset = "not given" if args.id is None else "empty" + named = f"--id ({unset}, so {ID_DERIVED_FROM})" + remediation = DERIVED_TOPIC_REMEDIATION + refuse_a_topic_github_cannot_set( + "scaffold-topic-too-long", policy.topic_for(project_id), project_id, + named, remediation) + + #: How a refusal names an elector `_elector` did not get from `--elected-by` #: (#282), so the sentence says where the value came from and not a flag #: nobody typed: the words #255's `plan` uses for its own fallback. @@ -1794,9 +1864,14 @@ def _scaffold(args) -> int: # `--local-remote-dir`, as a bare `.git` (#261): a `--project` of # 247 to 250 characters died in `Path.exists` with a traceback, and one # of 251 or more in the work directory, where adopt's `check` refused it. + # And without it, each is a GitHub repository's, which GitHub keeps to + # 100 characters: a `--project` of 96 to 246 planned and met GitHub's + # refusal at the first `gh repo create` (#838). + on_disk = args.local_remote_dir is not None refuse_unstorable_names( "scaffold-name-too-long", list(names.values()), - list(names.values()) if args.local_remote_dir is not None else []) + list(names.values()) if on_disk else [], + [] if on_disk else list(names.values())) # THE CLONE URL IS A PATH A FILE WILL HOLD (#433): under # `--local-remote-dir` it is the bare repository's own path, which the # READMEs print, so it is asked here, as the free-text values are asked @@ -1806,6 +1881,7 @@ def _scaffold(args) -> int: _refuse_an_unwritable_clone_url(local, urls["assembly"]) policy = NamingPolicy.load(SHAPE_ROOT / "contracts" / "repository-naming.yaml") _refuse_bad_id(project_id, args.id) + _refuse_what_github_cannot_hold(args, project_id, policy) # WHAT THE PROJECT DECLARES ABOUT ITSELF, before any name is judged. # With no `--pin`, a descendant-form name is a claim with no referent and # the DECLARED ROLE wins. With `--pin openGlass@`, `MedxGlass` IS a diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 55c8a808..40a42225 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -78,6 +78,9 @@ # What the Free-plan secrets hint goes by (#660): GitHub's three # visibilities, and the hint's own words, which stay in the pinned copy. from repo_shape import FREE_PLAN_HINT, VISIBILITY_CHOICES # noqa: E402 +# The topic's fixed prefix, which GitHub's limit on a topic bounds an id by +# (#838). +from repo_shape import TOPIC_PREFIX # noqa: E402 SHAPE_REPOSITORY = "opensoft/openRepoShape" @@ -4970,7 +4973,7 @@ def refuse_manifest_values(prefix: str, values: dict, policy: NamingPolicy, # --------------------------------------------------------------------------- -# A repository's name is also a directory's (#261) +# A repository's name is also a directory's (#261), and GitHub's (#838) # --------------------------------------------------------------------------- # # `checked_value` holds a name to 255 characters, `SAFE_ARG_RE`'s limit, and @@ -4982,17 +4985,70 @@ def refuse_manifest_values(prefix: str, values: dict, policy: NamingPolicy, # directory is asked about; the scaffold died the same way from 247, and from # 251 characters in the work directory, where adopt's `check` had already # refused the name `legs.spec` with `unsafe-value`. +# +# GITHUB KEEPS A NAME SHORTER STILL, AND A TOPIC AND AN OWNER TOO (#838). A +# repository's name is at most 100 characters, a topic at most 50, and a +# user's or an organisation's name at most 39. These are GitHub's own +# documented limits, and no run of this repository has measured them: none +# creates a real repository, and a probe against a real organisation is +# another lane's. Nothing asked them. A `--project` of 96 to 246 characters, +# or an `--org` of 40 to 255, passed the plan and met GitHub's refusal at the +# first `gh repo create`, with nothing made yet. An id over 39 characters, +# which a `--project` of 40 or more gives by default, passed the plan, and +# the scaffold created and seeded all three repositories before `gh repo +# edit --add-topic` failed, last; `adopt-project.py execute` failed there +# after both legs and the split were pushed. Each is asked before anything +# is created now, and only where a GitHub repository will be made: under +# `--local-remote-dir` nothing goes to GitHub, the limit on a name is the +# disk's, and no topic is set. `setup-project.py` cannot import this module +# (it runs before there is a checkout to import it from), so it says the +# same numbers in its own `--help`, and `tests/test_github_name_limits.py` +# holds the two to one number. #: The longest name a file or a directory may have, in bytes. NAME_MAX = 255 +#: The longest name GitHub gives a repository, in characters. +GITHUB_NAME_MAX = 100 +#: The longest topic GitHub sets on a repository, in characters. +GITHUB_TOPIC_MAX = 50 +#: The longest name GitHub gives a user or an organisation, in characters. +GITHUB_OWNER_MAX = 39 +#: The longest `` GitHub can hold a project's repositories under: +#: `-spec` and `-code` are five characters longer. +GITHUB_PROJECT_MAX = GITHUB_NAME_MAX - len("-spec") +#: The longest id whose topic, `xf-project-`, GitHub sets. +GITHUB_ID_MAX = GITHUB_TOPIC_MAX - len(TOPIC_PREFIX) + +#: What GitHub keeps a project's names to, as a remediation says it: the +#: repositories' names, and the id, by the topic it gives. A name refused +#: for GitHub's limit says the id's too: the default id is the project name +#: lowercased, so whoever picks a shorter name learns both numbers at once. +GITHUB_LIMITS = ( + f"GitHub keeps a repository's name to at most {GITHUB_NAME_MAX} " + f"characters, so a project name is at most {GITHUB_PROJECT_MAX}, and a " + f"topic to at most {GITHUB_TOPIC_MAX}, so the id in the topic " + f"`{TOPIC_PREFIX}`, which is the project name lowercased unless --id " + f"is given, is at most {GITHUB_ID_MAX}") + + +def _both_ends(name: str) -> str: + """`name` as a refusal shows it: whole, or cut in the middle. BOTH ENDS: + a name this long differs from the others by its tail, `-spec` or + `-code`, which a cut at the front loses.""" + return name if len(name) <= 27 else f"{name[:12]}...{name[-12:]}" + def refuse_unstorable_names(refusal: str, directories: list[str], - bare: list[str] = ()) -> None: + bare: list[str] = (), + github: list[str] = ()) -> None: """Raise `Refusal(refusal)` when a repository name cannot be a directory name: each of `directories` is made as it stands under the work - directory, each of `bare` as `.git` under `--local-remote-dir`.""" - problems = [] + directory, each of `bare` as `.git` under `--local-remote-dir`; + or when GitHub will not give it to a repository: each of `github` is + created there, by `gh repo create` (#838). The caller passes `github` + only where GitHub is asked, never under `--local-remote-dir`.""" + problems, unstorable = [], set() for names, suffix, where in ( (directories, "", "the directory this run makes for it under " "its work directory"), @@ -5001,18 +5057,70 @@ def refuse_unstorable_names(refusal: str, directories: list[str], for name in names: size = len(f"{name}{suffix}".encode("utf-8")) if size > NAME_MAX: - # BOTH ENDS: a name this long differs from the others by its - # tail, `-spec` or `-code`, which a cut at the front loses. - shown = (name if len(name) <= 27 - else f"{name[:12]}...{name[-12:]}") + unstorable.add(name) problems.append( - f"{shown}{suffix} is {size} bytes long, and {where} " - f"may be at most {NAME_MAX}") - if problems: + f"{_both_ends(name)}{suffix} is {size} bytes long, and " + f"{where} may be at most {NAME_MAX}") + # A name the disk cannot hold is not named again for GitHub's limit, + # which it breaks too: the remediation below states both numbers. + on_github = [f"{_both_ends(name)} is {len(name)} characters, and GitHub " + f"keeps a repository's name to at most {GITHUB_NAME_MAX}" + for name in github + if len(name) > GITHUB_NAME_MAX and name not in unstorable] + if problems or on_github: + # EACH LIMIT A NAME BROKE, AND ONLY THOSE: the disk's, GitHub's, or + # both, for a name too long for either. + limits = [f"a disk keeps a name of at most {NAME_MAX} bytes, which " + "under --local-remote-dir includes the `.git` each bare " + "repository adds"] if problems else [] + if on_github: + limits.append(GITHUB_LIMITS) raise Refusal( - refusal, "; ".join(problems) + ".", + refusal, "; ".join(problems + on_github) + ".", "Remediation: nothing was created. Use a shorter --project: the " "three repositories are named ``, `-spec` and " - "`-code`, and a disk keeps a name of at most " - f"{NAME_MAX} bytes, which under --local-remote-dir includes the " - "`.git` each bare repository adds.") + "`-code`, and " + "; and ".join(limits) + ".") + + +def refuse_a_topic_github_cannot_set(refusal: str, topic: str, + project_id: str, named: str, + remediation: str) -> None: + """Raise `Refusal(refusal)` when GitHub will not set `topic`, the + `xf-project-` a run sets on each repository it makes: GitHub keeps a + topic to `GITHUB_TOPIC_MAX` characters (#838). The topic is set LAST, + after the repositories exist, so it is asked before any is made. + + `project_id` is the id the topic carries, `named` how the caller names + it (the flag, or where its value came from), and `remediation` the + caller's own way out. Asked only where GitHub is: under + `--local-remote-dir` no topic is set. The id's own bound is read off + the topic, so it is the policy's template that decides it.""" + if len(topic) <= GITHUB_TOPIC_MAX: + return + prefix = topic[:len(topic) - len(project_id)] + raise Refusal( + refusal, + f"{named} is {_both_ends(project_id)}, {len(project_id)} characters, " + f"so the topic this run sets on each repository, `{prefix}`, is " + f"{len(topic)}, and GitHub keeps a topic to at most " + f"{GITHUB_TOPIC_MAX}: an id is at most " + f"{GITHUB_TOPIC_MAX - len(prefix)} characters", + remediation) + + +def refuse_an_owner_github_cannot_have(refusal: str, owner: str, named: str, + remediation: str) -> None: + """Raise `Refusal(refusal)` when `owner`, the organisation the run + creates repositories in, is longer than any name GitHub gives a user or + an organisation, `GITHUB_OWNER_MAX` characters (#838): no repository can + be created under it. `named` is how the caller names it, and + `remediation` its own way out. Asked only where GitHub is: under + `--local-remote-dir` the organisation names no account.""" + if len(owner) <= GITHUB_OWNER_MAX: + return + raise Refusal( + refusal, + f"{named} is {_both_ends(owner)}, {len(owner)} characters, and " + "GitHub keeps a user's or an organisation's name to at most " + f"{GITHUB_OWNER_MAX}, so no repository can be created under it", + remediation) diff --git a/setup-project.py b/setup-project.py index 58eae697..a051d005 100755 --- a/setup-project.py +++ b/setup-project.py @@ -132,8 +132,12 @@ the same thing as --project; give one or the other. --project the assembly-root name: ONE CamelCase token, no hyphen, underscore, dot or space. Prompted for when - omitted and a terminal is attached. + omitted and a terminal is attached. At most 95 + characters without --local-remote-dir: GitHub keeps + a repository's name to 100, and -spec adds 5. --id lowercase project id (default: project lowercased) + At most 39 characters without --local-remote-dir: + GitHub keeps the topic xf-project- to 50. --name "" display name (default: the project name) --visibility private|public|internal (default: private) --elected-by "" who is electing the shape (default: your gh login) @@ -153,7 +157,9 @@ --org override the detected organisation. REQUIRED when run outside a checkout of openRepoShape (self-bootstrap mode): there is no origin to read it - from. + from. At most 39 characters without + --local-remote-dir, GitHub's limit on an + organisation's name. --allow-upstream-org permit `--org opensoft` itself: opensoft is the upstream owner of openRepoShape, and almost never what you meant to scaffold into. diff --git a/tests/test_github_name_limits.py b/tests/test_github_name_limits.py new file mode 100644 index 00000000..eb5b7a5b --- /dev/null +++ b/tests/test_github_name_limits.py @@ -0,0 +1,734 @@ +# SPDX-License-Identifier: Apache-2.0 +"""What GitHub will not take is refused before anything is created (#838). + +GitHub keeps a repository's name to 100 characters, a topic to 50 and a +user's or an organisation's name to 39. Those are its DOCUMENTED limits: no +run here creates a real repository, so none of the three is measured, and the +fakes below stand in for GitHub by those numbers. Nothing asked them before +#838: + +* `scaffold-project.py` sets `xf-project-` on each repository LAST, with + `gh repo edit --add-topic`, after it has created and seeded all three. An + id over 39 characters, which a `--project` of 40 or more gives by default, + failed there; `adopt-project.py execute` failed there after both legs and + the split were pushed. +* A `--project` of 96 to 246 characters, and an `--org` of 40 to 255, passed + the plan and met GitHub's refusal at the first `gh repo create`. + +Each is now asked before any repository is made: by the scaffold's plan +before any process is started, under `--dry-run` alike, and by `execute` +before either leg exists. Only where a GitHub repository will be made: under +`--local-remote-dir` a name's limit is the disk's, which the same helper +still asks, and no topic is set. `setup-project.py` hands the three values to +the scaffold's plan at its step (5), which refuses them there, and its +`--help` says the same three numbers, which it cannot import. + +The in-process runs refuse every process start (`no_process`), so a refusal +that passes came before git, before `gh` and before the work directory. The +end-to-end runs give the tool a `gh` that refuses a topic over 50 characters +as GitHub documents it: they are POSIX only, as their `gh` is a `#!` script. +""" + +from __future__ import annotations + +import importlib.util +import os +import shlex +import sys +from pathlib import Path + +import pytest + +from conftest import (ADOPT, ORG, PTY_SKIP, REPO, SCAFFOLD, WINDOWS_SKIP, git, + make_source_repo, run_in_a_terminal, run_script, + write_plan) +from test_adopt_github_exits import (NORTHWIND, SOURCE_TREE, SPLIT_BRANCH, + gh_calls, github_run, heads, + needs_filter_repo) +from test_scaffold_leg_path_collisions import ( # noqa: F401 (a fixture) + ProcessStarted, no_process, +) +from test_setup_project_py import (SETUP_PROJECT, entry_point_module, + run_entry, terminal_env) + +sys.path.insert(0, str(REPO / "scripts")) +import shape_materialize # noqa: E402 +from repo_shape import TOPIC_PREFIX, NamingPolicy # noqa: E402 + +#: GitHub's documented limits, written out here rather than read back from +#: the module that asks them, so a change to either is a change a test sees. +NAME_MAX, TOPIC_MAX, OWNER_MAX = 100, 50, 39 +#: The longest project name and id they leave: `-spec` adds five to a name, +#: and the topic is `xf-project-`. +PROJECT_MAX, ID_MAX = NAME_MAX - 5, TOPIC_MAX - len("xf-project-") + +#: What each refusal is called. +NAME_TOO_LONG = "REFUSED scaffold-name-too-long" +TOPIC_TOO_LONG = "REFUSED scaffold-topic-too-long" +ORG_TOO_LONG = "REFUSED scaffold-org-too-long" + +IDENTITY = ("--org", ORG, "--elected-by", "Test Human", "--elected-on", + "2026-09-02") + + +def a_name(length: int) -> str: + """A project name of `length` characters: one CamelCase token.""" + return "A" + "b" * (length - 1) + + +def an_id(length: int) -> str: + return "a" * length + + +def an_org(length: int) -> str: + return "o" * length + + +# --- the numbers --------------------------------------------------------------- + +def test_the_limits_are_githubs_documented_numbers(): + assert shape_materialize.GITHUB_NAME_MAX == NAME_MAX + assert shape_materialize.GITHUB_TOPIC_MAX == TOPIC_MAX + assert shape_materialize.GITHUB_OWNER_MAX == OWNER_MAX + assert shape_materialize.GITHUB_PROJECT_MAX == PROJECT_MAX + assert PROJECT_MAX == 95 + assert shape_materialize.GITHUB_ID_MAX == ID_MAX + assert ID_MAX == 39 + assert len("-spec") == len("-code") + + +def test_the_id_bound_is_the_topic_the_naming_policy_gives(): + """`GITHUB_ID_MAX` is read off `TOPIC_PREFIX`; the topic a run sets is + the policy's template. The two are one prefix, so the bound the help + and the remediations state is the one the topic check applies.""" + policy = NamingPolicy.load(REPO / "contracts" / "repository-naming.yaml") + assert policy.topic_for("") == TOPIC_PREFIX + assert len(policy.topic_for(an_id(ID_MAX))) == TOPIC_MAX + + +def test_setup_project_says_the_numbers_it_cannot_import(): + """`setup-project.py` runs before there is a checkout to import from, so + its `--help` spells the numbers out; they are the plan's. #811 gives it + `GITHUB_NAME_MAX` and `PROJECT_NAME_MAX` of its own, for its own check + of `--project`: where it has them, they are the plan's too.""" + module = entry_point_module() + usage = " ".join(module.USAGE.split()) + for sentence in ( + f"At most {PROJECT_MAX} characters without --local-remote-dir: " + f"GitHub keeps a repository's name to {NAME_MAX}, and -spec adds " + "5.", + f"At most {ID_MAX} characters without --local-remote-dir: GitHub " + f"keeps the topic xf-project- to {TOPIC_MAX}.", + f"At most {OWNER_MAX} characters without --local-remote-dir, " + "GitHub's limit on an organisation's name."): + assert sentence in usage, sentence + for own, plans in (("GITHUB_NAME_MAX", NAME_MAX), + ("PROJECT_NAME_MAX", PROJECT_MAX)): + assert getattr(module, own, plans) == plans, own + + +def help_entry(text: str, flag: str) -> str: + """The `--help` entry of `flag` (`--id ID`), as one line: its own line + and each continuation under it, up to the next option.""" + lines = text.splitlines() + first = next(i for i, line in enumerate(lines) + if line.startswith(f" {flag}")) + entry = [lines[first]] + for line in lines[first + 1:]: + if not line.startswith(" " * 6): + break + entry.append(line) + return " ".join(" ".join(entry).split()) + + +#: What each direct tool's `--help` says of each bound, by the flag: the +#: scaffold asks them in its plan, adopt's `execute` before either leg. +SAID = { + SCAFFOLD: { + "--project PROJECT": ( + f"at most {PROJECT_MAX} characters without --local-remote-dir, " + f"as GitHub keeps a repository's name to {NAME_MAX} and `-spec` " + "adds 5"), + "--id ID": ( + f"at most {ID_MAX} characters without --local-remote-dir, as " + f"GitHub keeps the topic `xf-project-` to {TOPIC_MAX}"), + "--org ORG": ( + f"at most {OWNER_MAX} characters without --local-remote-dir, " + "GitHub's limit on an organisation's name"), + }, + ADOPT: { + "--project PROJECT": ( + f"`execute` refuses one over {PROJECT_MAX} characters without " + "--local-remote-dir: GitHub keeps a leg's name, " + f"`-spec`, to {NAME_MAX}."), + "--id ID": ( + f"`execute` refuses one over {ID_MAX} characters without " + f"--local-remote-dir: GitHub keeps a topic to {TOPIC_MAX}, and " + "the topic is `xf-project-`."), + "--org ORG": ( + f"`execute` refuses one over {OWNER_MAX} characters without " + "--local-remote-dir, GitHub's limit on an organisation's name."), + }, +} + + +@pytest.mark.parametrize("tool, args", [ + pytest.param(SCAFFOLD, ("--help",), id="scaffold"), + pytest.param(ADOPT, ("plan", "--help"), id="adopt-plan"), +]) +def test_each_direct_tool_says_the_bound_where_the_value_is_typed(tool, args): + """`--project`, `--id` and `--org` each say their bound in `--help`.""" + result = run_script(tool, *args) + assert result.returncode == 0, result.stderr + for flag, sentence in SAID[tool].items(): + entry = help_entry(result.stdout, flag) + assert sentence in entry, (flag, entry) + + +# --- the one helper the two tools ask ----------------------------------------- + +def refusal(call, *args): + with pytest.raises(shape_materialize.Refusal) as caught: + call(*args) + return caught.value + + +def test_a_repository_name_github_keeps_is_not_refused(): + refuse = shape_materialize.refuse_unstorable_names + assert refuse("r", [], [], ["a" * NAME_MAX]) is None + assert refuse("r", ["a" * 255], [], []) is None + + +def test_a_repository_name_over_githubs_limit_is_refused_by_both_ends(): + name = a_name(PROJECT_MAX + 1) + caught = refusal(shape_materialize.refuse_unstorable_names, "r", + [name], [], [name, f"{name}-spec", f"{name}-code"]) + assert caught.code == "r" + assert caught.detail == ( + "Abbbbbbbbbbb...bbbbbbb-spec is 101 characters, and GitHub keeps a " + "repository's name to at most 100; Abbbbbbbbbbb...bbbbbbb-code is " + "101 characters, and GitHub keeps a repository's name to at most " + "100.") + assert "a disk keeps" not in caught.remediation + assert caught.remediation.endswith( + "GitHub keeps a repository's name to at most 100 characters, so a " + "project name is at most 95, and a topic to at most 50, so the id in " + "the topic `xf-project-`, which is the project name lowercased " + "unless --id is given, is at most 39.") + + +def test_a_name_the_disk_cannot_hold_is_named_once_and_both_limits_said(): + """`<251>-spec` is over both limits: the disk's sentence names it, and + GitHub's does not name it again; the root, over GitHub's alone, is + named for GitHub's. The remediation states both numbers.""" + name = a_name(251) + names = [name, f"{name}-spec", f"{name}-code"] + caught = refusal(shape_materialize.refuse_unstorable_names, "r", names, + [], names) + sentences = caught.detail.rstrip(".").split("; ") + assert [s.split(" is ", 1)[0] for s in sentences] == [ + "Abbbbbbbbbbb...bbbbbbb-spec", "Abbbbbbbbbbb...bbbbbbb-code", + "Abbbbbbbbbbb...bbbbbbbbbbbb"] + assert "bytes long" in sentences[0] + assert "GitHub" in sentences[2] + assert "a disk keeps a name of at most 255 bytes" in caught.remediation + assert "; and GitHub keeps" in caught.remediation + + +def test_the_disk_alone_keeps_its_remediation_word_for_word(): + caught = refusal(shape_materialize.refuse_unstorable_names, "r", + [a_name(256)], [], []) + assert caught.remediation == ( + "Remediation: nothing was created. Use a shorter --project: the " + "three repositories are named ``, `-spec` and " + "`-code`, and a disk keeps a name of at most 255 bytes, " + "which under --local-remote-dir includes the `.git` each bare " + "repository adds.") + + +def test_a_topic_over_githubs_limit_is_refused(): + refuse = shape_materialize.refuse_a_topic_github_cannot_set + assert refuse("r", TOPIC_PREFIX + an_id(ID_MAX), an_id(ID_MAX), "--id", + "Remedy.") is None + caught = refusal(refuse, "r", TOPIC_PREFIX + an_id(ID_MAX + 1), + an_id(ID_MAX + 1), "--id", "Remedy.") + assert caught.code == "r" + assert caught.remediation == "Remedy." + assert caught.detail == ( + "--id is aaaaaaaaaaaa...aaaaaaaaaaaa, 40 characters, so the topic " + "this run sets on each repository, `xf-project-`, is 51, and " + "GitHub keeps a topic to at most 50: an id is at most 39 characters") + + +def test_an_organisation_over_githubs_limit_is_refused(): + refuse = shape_materialize.refuse_an_owner_github_cannot_have + assert refuse("r", an_org(OWNER_MAX), "--org", "Remedy.") is None + caught = refusal(refuse, "r", an_org(OWNER_MAX + 1), "--org", "Remedy.") + assert caught.detail == ( + "--org is oooooooooooo...oooooooooooo, 40 characters, and GitHub " + "keeps a user's or an organisation's name to at most 39, so no " + "repository can be created under it") + + +# --- scaffold-project.py, in process: before any process starts --------------- + +@pytest.fixture(scope="module") +def scaffold(): + spec = importlib.util.spec_from_file_location("scaffold_github_limits", + SCAFFOLD) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def refused_plan(scaffold, capsys, *flags: str, dry_run: bool = True) -> str: + """`main()` against GitHub with `flags`: it refuses with no process + started (`no_process` holds) and no plan printed. Returns stderr.""" + code = scaffold.main([*IDENTITY, *flags, + *(["--dry-run"] if dry_run else [])]) + out, err = capsys.readouterr() + assert (code, out) == (2, ""), out + err + return err + + +def goes_on(scaffold, capsys, *flags: str) -> None: + """`main()` with `flags` passes every question GitHub's limits ask and + goes on to its first process, which `no_process` refuses.""" + with pytest.raises(ProcessStarted): + scaffold.main([*IDENTITY, *flags, "--dry-run"]) + assert "too-long" not in capsys.readouterr().err + + +#: `(flags at the bound, flags one past it, the refusal)`: N and N+1 +#: characters either side of each number. +EDGES = { + "derived-id": (("--project", a_name(ID_MAX)), + ("--project", a_name(ID_MAX + 1)), TOPIC_TOO_LONG), + "id": (("--project", "Atlas", "--id", an_id(ID_MAX)), + ("--project", "Atlas", "--id", an_id(ID_MAX + 1)), TOPIC_TOO_LONG), + "project": (("--project", a_name(PROJECT_MAX), "--id", "atlas"), + ("--project", a_name(PROJECT_MAX + 1), "--id", "atlas"), + NAME_TOO_LONG), + "org": (("--project", "Atlas", "--org", an_org(OWNER_MAX)), + ("--project", "Atlas", "--org", an_org(OWNER_MAX + 1)), + ORG_TOO_LONG), +} + + +@pytest.mark.parametrize("edge", EDGES) +def test_the_value_at_githubs_bound_goes_on(scaffold, no_process, capsys, + edge): + goes_on(scaffold, capsys, *EDGES[edge][0]) + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real"]) +@pytest.mark.parametrize("edge", EDGES) +def test_one_past_githubs_bound_is_refused_before_any_process( + scaffold, no_process, capsys, edge, dry_run): + _, over, code = EDGES[edge] + err = refused_plan(scaffold, capsys, *over, dry_run=dry_run) + assert err.startswith(f"{code}: "), err + assert "nothing was created" in err + + +def test_a_derived_id_is_named_as_derived_and_mended_with_an_id( + scaffold, no_process, capsys): + err = refused_plan(scaffold, capsys, "--project", a_name(ID_MAX + 1)) + assert err.startswith( + f"{TOPIC_TOO_LONG}: --id (not given, so --project lowercased) is " + "abbbbbbbbbbb...bbbbbbbbbbbb, 40 characters, "), err + assert err.endswith( + "Pass an explicit --id of at most 39 characters: the id need not be " + "--project lowercased, and --project, which names the three " + "repositories, stays as it is.\n"), err + + +def test_an_empty_id_is_named_as_empty(scaffold, no_process, capsys): + """`--id ''` falls back to `--project` lowercased, as `_identity` reads + it, and is named as `_refuse_bad_id` names it: passed, and empty.""" + err = refused_plan(scaffold, capsys, "--project", a_name(ID_MAX + 1), + "--id", "") + assert err.startswith( + f"{TOPIC_TOO_LONG}: --id (empty, so --project lowercased) is "), err + + +def test_a_passed_id_is_named_as_passed(scaffold, no_process, capsys): + err = refused_plan(scaffold, capsys, "--project", "Atlas", "--id", + an_id(ID_MAX + 1)) + assert err.startswith(f"{TOPIC_TOO_LONG}: --id is aaaa"), err + assert err.endswith("Pass a shorter --id, of at most 39 characters.\n") + + +def test_a_long_name_with_a_short_id_still_plans(scaffold, no_process, + capsys): + """A name of 40 to 95 characters keeps its name: a shorter `--id` is + the way round the topic's bound.""" + for length in (ID_MAX + 1, 60, PROJECT_MAX): + goes_on(scaffold, capsys, "--project", a_name(length), "--id", "atl") + + +def test_none_of_githubs_limits_is_asked_of_a_local_run( + scaffold, no_process, capsys, tmp_path): + """Under `--local-remote-dir` nothing goes to GitHub: no topic is set, + the organisation names no account, and a name's limit is the disk's.""" + goes_on(scaffold, capsys, "--project", a_name(PROJECT_MAX + 1), "--org", + an_org(OWNER_MAX + 1), "--local-remote-dir", + str(tmp_path / "remotes")) + assert not (tmp_path / "remotes").exists() + + +def test_the_topic_is_refused_before_a_pin_asks_gh(scaffold, no_process, + capsys, monkeypatch): + """`_declaration` reads a `--pin`'s digest with `gh api` when no + `--pin-source` is given; the topic and the organisation are refused + before it is called.""" + called = [] + monkeypatch.setattr(scaffold, "_declaration", + lambda args: called.append(args) or ({}, set(), (), + {})) + pin = ("--pin", f"openGlass@{'a' * 40}") + refused_plan(scaffold, capsys, "--project", a_name(ID_MAX + 1), *pin) + refused_plan(scaffold, capsys, "--project", "Atlas", "--org", + an_org(OWNER_MAX + 1), *pin) + assert called == [] + goes_on(scaffold, capsys, "--project", a_name(ID_MAX), *pin) + assert len(called) == 1, "the control never reached `_declaration`" + + +# --- scaffold-project.py end to end: GitHub's refusal, by its numbers --------- + +#: A `gh` with no network that answers as GitHub documents: a repository it +#: has not got is not found, `repo create` makes one, and `repo edit +#: --add-topic` refuses a topic over 50 characters. Every call is logged. +FAKE_GH = b"""#!/bin/sh +printf '%s\\n' "$*" >> "$FAKE_GH_LOG" +case "$1 $2" in + "repo view") + echo "GraphQL: Could not resolve to a Repository with the name '$3'." >&2 + exit 1 ;; + "repo create") exit 0 ;; + "repo edit") + topic=$5 + if [ "${#topic}" -gt 50 ]; then + echo "HTTP 422: Topics must consist of 50 characters or less" >&2 + exit 1 + fi + exit 0 ;; +esac +echo "fake gh: no network here" >&2 +exit 1 +""" + + +def fake_github(tmp_path: Path) -> tuple[dict, Path]: + """The environment of a run that asks `FAKE_GH`, and its log.""" + bin_dir = tmp_path / "fakebin" + bin_dir.mkdir() + gh = bin_dir / "gh" + gh.write_bytes(FAKE_GH) + gh.chmod(0o755) + config = tmp_path / "gh-config" + config.mkdir() + log = tmp_path / "gh.log" + return ({"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "FAKE_GH_LOG": str(log), "GH_TOKEN": "bogus-token-for-tests", + "GH_CONFIG_DIR": str(config)}, log) + + +def scaffold_on_github(tmp_path: Path, project: str): + """A real scaffold of `project` against `FAKE_GH`, pushing nothing. + Public, so no `gh` call asks the organisation's plan.""" + env, log = fake_github(tmp_path) + result = run_script(SCAFFOLD, *IDENTITY, "--project", project, + "--visibility", "public", "--no-push", "--work-dir", + str(tmp_path / "work"), env=env) + return result, log + + +@WINDOWS_SKIP +def test_an_id_github_cannot_set_is_refused_before_a_repository_exists( + tmp_path): + """On main the run created all three repositories and seeded them, and + then GitHub refused `--add-topic`: exit 2 with three repositories made. + Now nothing reaches `gh`.""" + result, log = scaffold_on_github(tmp_path, a_name(ID_MAX + 1)) + assert result.returncode == 2, result.stderr + result.stdout + assert TOPIC_TOO_LONG in result.stderr + assert not log.exists(), log.read_text(encoding="utf-8") + assert not (tmp_path / "work").exists() + + +@WINDOWS_SKIP +def test_an_id_at_githubs_bound_is_set_on_all_three(tmp_path): + name = a_name(ID_MAX) + result, log = scaffold_on_github(tmp_path, name) + assert result.returncode == 0, result.stderr + result.stdout + calls = log.read_text(encoding="utf-8").splitlines() + topic = TOPIC_PREFIX + name.lower() + assert [c for c in calls if c.startswith("repo edit")] == [ + f"repo edit {ORG}/{name}{tail} --add-topic {topic}" + for tail in ("", "-spec", "-code")] + + +# --- setup-project.py: its step (5), the scaffold's plan, refuses ------------- + +#: The `gh` a setup run asks: the preflight's three questions answered, a +#: repository it has not got not found (the plan's question), and anything +#: else refused, so no run here reaches GitHub. +SETUP_GH = b"""#!/bin/sh +case "$1 $2" in + "--version "*) echo "gh version 2.50.0 (stub)" ;; + "auth "*) exit 0 ;; + "api user") echo tester ;; + "repo view") + echo "GraphQL: Could not resolve to a Repository with the name '$3'." >&2 + exit 1 ;; + *) echo "stub gh: refused $*" >&2; exit 97 ;; +esac +""" + + +def setup_env(tmp_path: Path) -> dict: + """`SETUP_GH` first on PATH, and a global git config of the run's own + that names it as github.com's credential helper: a preflight in a + terminal otherwise offers `gh auth setup-git` on a machine with none, as + CI's runner is, and waits there for an answer nobody types. A file, not + git's command scope, so no entry count is worked out by hand (#818).""" + bin_dir = tmp_path / "setupbin" + bin_dir.mkdir() + gh = bin_dir / "gh" + gh.write_bytes(SETUP_GH) + gh.chmod(0o755) + helper = f"!{shlex.quote(str(gh))} auth git-credential" + assert '"' not in helper, helper + assert "\\" not in helper, helper + config = tmp_path / "setup.gitconfig" + config.write_bytes(b'[credential "https://github.com"]\n\thelper = "' + + helper.encode("utf-8") + b'"\n') + return {"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "GIT_CONFIG_GLOBAL": str(config)} + + +def setup_flags(tmp_path: Path, *flags: str) -> list: + """A setup run that ends at the plan (`-- --dry-run`).""" + return ["--org", ORG, "--elected-by", "Tester", *flags, "--into", + str(tmp_path / "parent" / "into"), "--", "--dry-run"] + + +#: `(flags, the refusal or None)`: a GitHub run, at each bound and past it. +SETUP_RUNS = { + "derived-id-at": (("--project", a_name(ID_MAX)), None), + "derived-id-over": (("--project", a_name(ID_MAX + 1)), TOPIC_TOO_LONG), + "long-name-short-id": (("--project", a_name(PROJECT_MAX), "--id", + "atl"), None), + "id-over": (("--project", "Atlas", "--id", an_id(ID_MAX + 1)), + TOPIC_TOO_LONG), + "org-at": (("--project", "Atlas", "--org", an_org(OWNER_MAX)), None), + "org-over": (("--project", "Atlas", "--org", an_org(OWNER_MAX + 1)), + ORG_TOO_LONG), +} + + +@WINDOWS_SKIP +@pytest.mark.parametrize("case", SETUP_RUNS) +def test_setup_refuses_at_the_plan_what_github_will_not_take(tmp_path, case): + flags, refused = SETUP_RUNS[case] + result = run_entry(*setup_flags(tmp_path, *flags), + extra_env=setup_env(tmp_path)) + assert not (tmp_path / "parent").exists() + if refused is None: + assert result.returncode == 0, result.stderr + result.stdout + assert "too-long" not in result.stderr + return + assert result.returncode == 2, result.stderr + result.stdout + assert refused in result.stderr, result.stderr + assert "REFUSED: the plan could not be produced (see above)." in \ + result.stderr + assert "(5) the plan" in result.stdout + + +@WINDOWS_SKIP +def test_setup_asks_none_of_them_of_a_local_run(tmp_path): + result = run_entry(*setup_flags( + tmp_path, "--project", a_name(PROJECT_MAX + 1), "--org", + an_org(OWNER_MAX + 1), "--local-remote-dir", + str(tmp_path / "remotes"))) + assert result.returncode == 0, result.stderr + result.stdout + assert "too-long" not in result.stderr + assert not (tmp_path / "remotes").exists() + + +@PTY_SKIP +@pytest.mark.parametrize("length", [ID_MAX, ID_MAX + 1]) +def test_a_name_typed_at_the_prompt_is_held_to_the_topic_bound(tmp_path, + length): + """The name typed at the prompt becomes the scaffold's `--project`, and + its derived id the topic's: 39 characters plan, 40 are refused at the + plan with `--id` as the way round, and nothing exists.""" + env = terminal_env() + env.update(setup_env(tmp_path)) + status, screen, answered = run_in_a_terminal( + [sys.executable, str(SETUP_PROJECT), *setup_flags(tmp_path)], + cwd=REPO, env=env, answers=[a_name(length) + "\n"], + prompt="project name (ONE CamelCase token, e.g. Atlas): ", + timeout=120) + assert answered == 1, f"it never asked for a project name:\n{screen}" + assert not (tmp_path / "parent").exists() + if length == ID_MAX: + assert status == 0, screen + assert "too-long" not in screen + else: + assert status == 2, screen + assert TOPIC_TOO_LONG in screen + assert "Pass an explicit --id" in screen + + +# --- adopt-project.py execute: before either leg exists ----------------------- + +def a_root_named(org: str, project: str): + """`github_run`'s `before_plan`: the source as `org/project` on the + stand-in for github.com, which is where an in-place adoption pushes its + split. `github_run` places it as `testorg/Northwind` itself.""" + def place(source: Path, github: Path) -> None: + root = github / org / f"{project}.git" + if not root.exists(): + git("clone", "-q", "--bare", ".", str(root), cwd=source) + return place + + +def assert_no_leg(github: Path, org: str, project: str) -> None: + for leg in ("spec", "code"): + assert not (github / org / f"{project}-{leg}.git").exists(), leg + + +#: How a refusal says that `execute` made nothing: the name's is the disk's +#: sentence, which the scaffold shares; the other two are `execute`'s own. +NO_REPOSITORY = "Remediation: no repository was created." +NOTHING = "Remediation: nothing was created." + +#: `(plan flags, the organisation, the project, the refusal, its +#: remediation's first sentence)`: one past each bound, each refused by +#: `execute` before a `gh` call or a leg. +ADOPT_OVER = { + "id": (("--id", an_id(ID_MAX + 1)), ORG, NORTHWIND, + "REFUSED adopt-topic-too-long: ", NO_REPOSITORY), + "derived-id": (("--project", a_name(ID_MAX + 1)), ORG, + a_name(ID_MAX + 1), "REFUSED adopt-topic-too-long: ", + NO_REPOSITORY), + "project": (("--project", a_name(PROJECT_MAX + 1), "--id", "atl"), ORG, + a_name(PROJECT_MAX + 1), "REFUSED adopt-name-too-long: ", + NOTHING), + "org": (("--org", an_org(OWNER_MAX + 1)), an_org(OWNER_MAX + 1), + NORTHWIND, "REFUSED adopt-org-too-long: ", NO_REPOSITORY), +} + + +@pytest.fixture(scope="module") +def adopter(): + spec = importlib.util.spec_from_file_location("adopt_github_limits", + ADOPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("unset", [False, True], ids=["id", "no-id"]) +def test_executes_topic_refusal_names_the_id_by_where_it_came_from( + adopter, tmp_path, unset): + """`plan` writes `id:` whether or not `--id` was passed; a plan edited + to hold none takes `legs.assembly` lowercased, and is named so.""" + project = a_name(ID_MAX + 1) + source = make_source_repo(tmp_path / NORTHWIND, tree=SOURCE_TREE, + edits=()) + path = tmp_path / "adoption-plan.yaml" + written = write_plan(source, path, project=project) + assert written.returncode == 0, written.stderr + written.stdout + if unset: + text = path.read_bytes() + line = f"\nid: {project.lower()}\n".encode("ascii") + assert text.count(line) == 1 + path.write_bytes(text.replace(line, b"\n")) + plan = adopter.Plan.load(path) + naming = NamingPolicy.load(REPO / "contracts" / "repository-naming.yaml") + with pytest.raises(adopter.Refusal) as caught: + adopter._refuse_a_topic_github_cannot_set( + plan, naming.topic_for(plan.project_id)) + named = adopter.DERIVED_ID if unset else "`id:`" + assert caught.value.code == "adopt-topic-too-long" + assert caught.value.detail.startswith( + f"{path}: {named} is abbbbbbbbbbb...bbbbbbbbbbbb, 40 characters, ") + assert caught.value.remediation.startswith( + "Remediation: no repository was created. Fix `id:` in the plan") + + +@WINDOWS_SKIP +@needs_filter_repo +@pytest.mark.parametrize("case", ADOPT_OVER) +def test_execute_refuses_what_github_will_not_take_before_a_leg(tmp_path, + case): + """On main an id over 39 characters adopted, pushed both legs and the + split, and ended at the topic, exit 2 (`The split IS pushed`); a leg + name over 100 or an organisation over 39 ended at the first `gh repo + create`.""" + flags, org, project, refused, made = ADOPT_OVER[case] + result, github = github_run(tmp_path, "", plan_flags=flags, + before_plan=a_root_named(org, project)) + assert result.returncode == 2, result.stderr + result.stdout + assert refused in result.stderr, result.stderr + assert gh_calls(github) == [] + assert_no_leg(github, org, project) + assert SPLIT_BRANCH not in heads(github / org / f"{project}.git") + assert f"\n{made} " in result.stderr, result.stderr + + +#: `(plan flags, the organisation, the project)`: at each bound, adopted to +#: the end, the topic set on all three. +ADOPT_AT = { + "id": (("--id", an_id(ID_MAX)), ORG, NORTHWIND), + "long-name-short-id": (("--project", a_name(PROJECT_MAX), "--id", "atl"), + ORG, a_name(PROJECT_MAX)), + "org": (("--org", an_org(OWNER_MAX)), an_org(OWNER_MAX), NORTHWIND), +} + + +@WINDOWS_SKIP +@needs_filter_repo +@pytest.mark.parametrize("case", ADOPT_AT) +def test_execute_adopts_at_githubs_bounds(tmp_path, case): + flags, org, project = ADOPT_AT[case] + result, github = github_run(tmp_path, "", plan_flags=flags, + before_plan=a_root_named(org, project)) + assert result.returncode == 0, result.stderr + result.stdout + assert [c for c in gh_calls(github) if c.startswith("repo edit")] == [ + f"repo edit {org}/{project}{tail}" for tail in ("", "-spec", "-code")] + for leg in ("spec", "code"): + assert heads(github / org / f"{project}-{leg}.git") == ["main"] + + +#: A name this long, three times over, under pytest's temporary root is a +#: path past Windows' 260 characters. +LONG_PATHS_SKIP = pytest.mark.skipif( + os.name == "nt", reason="the leg's work tree is a path Windows will not " + "open without long paths turned on") + + +@LONG_PATHS_SKIP +@needs_filter_repo +def test_execute_asks_none_of_them_under_a_local_remote_dir(tmp_path): + """A name, an organisation and an id GitHub would refuse adopt into + bare repositories on disk, where GitHub is never asked.""" + project = a_name(PROJECT_MAX + 1) + source = make_source_repo(tmp_path / NORTHWIND, tree=SOURCE_TREE, + edits=()) + plan = tmp_path / "adoption-plan.yaml" + written = write_plan(source, plan, project=project, + org=an_org(OWNER_MAX + 1)) + assert written.returncode == 0, written.stderr + written.stdout + remotes = tmp_path / "remotes" + result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(remotes), "--work-dir", + str(tmp_path / "work")) + assert result.returncode == 0, result.stderr + result.stdout + assert "too-long" not in result.stderr + for leg in ("spec", "code"): + assert (remotes / f"{project}-{leg}.git").is_dir(), leg diff --git a/tests/test_scaffold_manifest_values.py b/tests/test_scaffold_manifest_values.py index 042806c1..2d40bcba 100644 --- a/tests/test_scaffold_manifest_values.py +++ b/tests/test_scaffold_manifest_values.py @@ -631,13 +631,15 @@ def test_the_longest_name_a_disk_keeps_is_still_accepted(tmp_path, monkeypatch, """246 characters is `-spec.git` at exactly 255 bytes. A dry run only: a real tree this deep is a Windows path the OS will not open. Without `--local-remote-dir` the dry run asks GitHub whether the three - exist (#537), so that half runs in process and asks a stand-in.""" + exist (#537), so that half runs in process and asks a stand-in. There + the longest is GitHub's, not the disk's (#838): 95 characters, with an + `--id` short enough for the topic (`tests/test_github_name_limits.py`).""" result = scaffold(tmp_path, "--project", "N" * 246, "--dry-run") assert result.returncode == 0, result.stderr + result.stdout code, _out, err, asked = scaffold_dry_run_on_github( - monkeypatch, capsys, *IDENTITY, "--project", "N" * 250) + monkeypatch, capsys, *IDENTITY, "--project", "N" * 95, "--id", "n") assert code == 0, err - assert asked == [f"{ORG}/{'N' * 250}{tail}" for tail in + assert asked == [f"{ORG}/{'N' * 95}{tail}" for tail in ("", "-spec", "-code")] From 71936633a90aacaa54a9021695001e7124fce723 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 22:04:31 +0000 Subject: [PATCH 2/3] adopt-project.py: a leg GitHub will not create, named after a source of 96 to 100 characters, is mended by renaming the source on GitHub first, so `adopt-name-too-long` no longer says to shorten a `--project` an in-place adoption cannot change (#838) Fix round 1 on #859, after rv859's review. adopt-project.py: without `--local-remote-dir` the assembly root is the source's own repository on GitHub, which GitHub lets be named with up to 100 characters, so a source of 96 to 100 gives legs of 101 to 105, which `adopt-name-too-long` now refuses. Its remediation said "Use a shorter --project", which plans another repository; `git ls-remote` could not read it. Where a leg over 100 is named after the root, the remediation now says GitHub's numbers and then `_rename_first`'s way out, as `adopt-bad-id` says it: rename the source on GitHub, to at most 95 characters, and plan again. A leg the plan was edited to name otherwise, and the disk's limit under `--local-remote-dir`, keep their remediations word for word. Comments and docstrings: the base planned a `--project` of 96 to 250 characters in GitHub mode, not 96 to 246. Of the three numbers only the 100 and the 50 are documented on docs.github.com; for 39 the docs give the limit on a user's name (Enterprise Cloud's username considerations) and nothing for an organisation's. The scaffold's GitHub refusals come before any process only where `--elected-by` or GIT_AUTHOR_NAME names the elector; otherwise `git config user.name` runs first. The previous commit's message says otherwise on all three. tests/test_github_name_limits.py: a source named with 96 and with 100 characters each refuses with the rename-first remediation, and a leg the plan names otherwise and the disk's limit keep theirs; `execute` without `--yes`, on a stdin that is not a terminal, refuses `adopt-topic-too-long` and `adopt-org-too-long`, not `adopt-unconfirmed`, with a control that reaches `adopt-unconfirmed`; and the scaffold's and `execute`'s own remediations are held word for word. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 43 +++++- scaffold-project.py | 7 +- scripts/shape_materialize.py | 33 ++--- tests/test_github_name_limits.py | 239 +++++++++++++++++++++++++------ 4 files changed, 255 insertions(+), 67 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index fdb52c1c..448ef65e 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -253,8 +253,8 @@ # What GitHub keeps a repository's name, a topic and an organisation's name # to, asked by `execute` before either leg exists where GitHub is (#838). from shape_materialize import ( # noqa: E402 - GITHUB_ID_MAX, GITHUB_NAME_MAX, GITHUB_OWNER_MAX, GITHUB_PROJECT_MAX, - GITHUB_TOPIC_MAX, refuse_a_topic_github_cannot_set, + GITHUB_ID_MAX, GITHUB_LIMITS, GITHUB_NAME_MAX, GITHUB_OWNER_MAX, + GITHUB_PROJECT_MAX, GITHUB_TOPIC_MAX, refuse_a_topic_github_cannot_set, refuse_an_owner_github_cannot_have, ) # Whether a value this tool writes into a file is text a file can hold @@ -10730,9 +10730,21 @@ def _repository_urls(args, plan: Plan, names: dict, # GitHub keeps to 100 characters, under an organisation it keeps to 39: # either passed to the first `gh repo create` (#838). legs = [names["spec"], names["code"]] - refuse_unstorable_names( - "adopt-name-too-long", list(names.values()), legs if local else [], - [] if local else legs) + try: + refuse_unstorable_names( + "adopt-name-too-long", list(names.values()), + legs if local else [], [] if local else legs) + except Refusal as exc: + if local or not _a_leg_named_after_a_long_root(names): + raise + # THE SOURCE'S OWN NAME IS NOT `--project`'S TO SHORTEN (#859's + # review, SHOULD 1). Without `--local-remote-dir` the assembly root + # is the source's own repository on GitHub, which GitHub lets be + # named with up to 100 characters, and a leg named after one of 96 + # to 100 is over that limit. "Use a shorter --project" planned + # another repository, which `git ls-remote` could not read. + raise Refusal(exc.code, exc.detail, _rename_first( + repositories["assembly"], SOURCE_NAME_TOO_LONG)) from None if not local: refuse_an_owner_github_cannot_have( "adopt-org-too-long", org, f"{plan.path}: `org:`", @@ -10754,6 +10766,24 @@ def _repository_urls(args, plan: Plan, names: dict, f"re-run `plan` with an explicit --id, of at most {GITHUB_ID_MAX} " "characters: the id need not be the project's name lowercased, and the " "name, which the assembly root already has, stays as it is.") +#: What `adopt-name-too-long` says before `_rename_first`'s way out, where a +#: leg GitHub will not create is named after the source (#859's review, +#: SHOULD 1), as `adopt-bad-id` says why the source's own name will not do. +SOURCE_NAME_TOO_LONG = ( + "nothing was created. The legs are named `-spec` and " + "`-code`, and --project is the source's own name, which must be " + f"at most {GITHUB_PROJECT_MAX} characters: {GITHUB_LIMITS}. ") + + +def _a_leg_named_after_a_long_root(names: dict) -> bool: + """Whether a leg over GitHub's `GITHUB_NAME_MAX` is named after the + assembly root, `-spec` or `-code`, as `plan` names both: + its length is then the root's, the source's own name (#859's review, + SHOULD 1). A leg the plan was edited to name otherwise is the plan's own + to shorten, and keeps the remediation it had.""" + return any(len(names[role]) > GITHUB_NAME_MAX + and names[role] == f"{names['assembly']}-{role}" + for role in ("spec", "code")) def _refuse_a_topic_github_cannot_set(plan: Plan, topic: str) -> None: @@ -12864,7 +12894,8 @@ def _is_the_source(repository: str, org: str, name: str) -> bool: def _rename_first(repository: str, said: str = "") -> str: """The remediation where the source's own name is one the naming policy - refuses: it cannot be adopted in place under that name, and renaming it + refuses, or one too long for GitHub to create a leg named after it + (#838): it cannot be adopted in place under that name, and renaming it is a human's decision that comes first (#499). `said` is what the refusal says before that, a sentence or none. THE ONE TEXT of it. diff --git a/scaffold-project.py b/scaffold-project.py index a4302d40..9c49845b 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -872,7 +872,10 @@ def _refuse_what_github_cannot_hold(args, project_id: str, policy) -> None: """`scaffold-org-too-long` and `scaffold-topic-too-long`: an `--org` GitHub gives no organisation, and an id whose `xf-project-` topic GitHub will not set (#838), refused where GitHub is asked, before any - process is started and under `--dry-run` alike. + repository or work directory exists and under `--dry-run` alike. No + process has started yet where `--elected-by` or GIT_AUTHOR_NAME names + the elector; otherwise `_identity` has asked `git config user.name` + first, as it has before `scaffold-bad-id`. THE TOPIC IS SET LAST (`_set_topics`), after `_create_remotes`, `_seed_legs` and `_build_assembly_root`, so an id over 39 characters, @@ -1865,7 +1868,7 @@ def _scaffold(args) -> int: # 247 to 250 characters died in `Path.exists` with a traceback, and one # of 251 or more in the work directory, where adopt's `check` refused it. # And without it, each is a GitHub repository's, which GitHub keeps to - # 100 characters: a `--project` of 96 to 246 planned and met GitHub's + # 100 characters: a `--project` of 96 to 250 planned and met GitHub's # refusal at the first `gh repo create` (#838). on_disk = args.local_remote_dir is not None refuse_unstorable_names( diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 40a42225..ca045b68 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -4987,23 +4987,24 @@ def refuse_manifest_values(prefix: str, values: dict, policy: NamingPolicy, # refused the name `legs.spec` with `unsafe-value`. # # GITHUB KEEPS A NAME SHORTER STILL, AND A TOPIC AND AN OWNER TOO (#838). A -# repository's name is at most 100 characters, a topic at most 50, and a -# user's or an organisation's name at most 39. These are GitHub's own -# documented limits, and no run of this repository has measured them: none -# creates a real repository, and a probe against a real organisation is -# another lane's. Nothing asked them. A `--project` of 96 to 246 characters, -# or an `--org` of 40 to 255, passed the plan and met GitHub's refusal at the +# repository's name is at most 100 characters and a topic at most 50, both +# documented on docs.github.com. For 39, the docs give the limit on a user's +# name (Enterprise Cloud's username considerations), and nothing for an +# organisation's; 39 is asked of both. No run of this repository has measured +# any of the three: none creates a real repository, and a probe against a real +# organisation is another lane's. Nothing asked them. A `--project` of 96 to +# 250 characters (250 makes the longest `-spec` a directory can be named), or +# an `--org` of 40 to 255, passed the plan and met GitHub's refusal at the # first `gh repo create`, with nothing made yet. An id over 39 characters, -# which a `--project` of 40 or more gives by default, passed the plan, and -# the scaffold created and seeded all three repositories before `gh repo -# edit --add-topic` failed, last; `adopt-project.py execute` failed there -# after both legs and the split were pushed. Each is asked before anything -# is created now, and only where a GitHub repository will be made: under -# `--local-remote-dir` nothing goes to GitHub, the limit on a name is the -# disk's, and no topic is set. `setup-project.py` cannot import this module -# (it runs before there is a checkout to import it from), so it says the -# same numbers in its own `--help`, and `tests/test_github_name_limits.py` -# holds the two to one number. +# which a `--project` of 40 or more gives by default, passed the plan, and the +# scaffold created and seeded all three repositories before `gh repo edit +# --add-topic` failed, last; `adopt-project.py execute` failed there after both +# legs and the split were pushed. Each is asked before anything is created now, +# and only where a GitHub repository will be made: under `--local-remote-dir` +# nothing goes to GitHub, the limit on a name is the disk's, and no topic is +# set. `setup-project.py` cannot import this module (it runs before there is a +# checkout to import it from), so it says the same numbers in its own `--help`, +# and `tests/test_github_name_limits.py` holds the two to one number. #: The longest name a file or a directory may have, in bytes. NAME_MAX = 255 diff --git a/tests/test_github_name_limits.py b/tests/test_github_name_limits.py index eb5b7a5b..e9cd77b4 100644 --- a/tests/test_github_name_limits.py +++ b/tests/test_github_name_limits.py @@ -2,27 +2,37 @@ """What GitHub will not take is refused before anything is created (#838). GitHub keeps a repository's name to 100 characters, a topic to 50 and a -user's or an organisation's name to 39. Those are its DOCUMENTED limits: no -run here creates a real repository, so none of the three is measured, and the -fakes below stand in for GitHub by those numbers. Nothing asked them before -#838: +user's or an organisation's name to 39. The 100 and the 50 are documented on +docs.github.com; for 39 the docs give the limit on a user's name (Enterprise +Cloud's username considerations) and nothing for an organisation's. No run +here creates a real repository, so none of the three is measured. The fake +`gh` below holds only the topic to its number, refusing a topic over 50 +characters; a name over 100 or an organisation over 39 never reaches it. +Nothing asked them before #838: * `scaffold-project.py` sets `xf-project-` on each repository LAST, with `gh repo edit --add-topic`, after it has created and seeded all three. An id over 39 characters, which a `--project` of 40 or more gives by default, failed there; `adopt-project.py execute` failed there after both legs and the split were pushed. -* A `--project` of 96 to 246 characters, and an `--org` of 40 to 255, passed +* A `--project` of 96 to 250 characters, and an `--org` of 40 to 255, passed the plan and met GitHub's refusal at the first `gh repo create`. -Each is now asked before any repository is made: by the scaffold's plan -before any process is started, under `--dry-run` alike, and by `execute` -before either leg exists. Only where a GitHub repository will be made: under +Each is now asked before any repository is made: by the scaffold's plan, +under `--dry-run` alike, before any process is started where `--elected-by` +or GIT_AUTHOR_NAME names the elector (otherwise `git config user.name` is +read first), and by `execute` before `_confirm` and before either leg +exists. Only where a GitHub repository will be made: under `--local-remote-dir` a name's limit is the disk's, which the same helper still asks, and no topic is set. `setup-project.py` hands the three values to the scaffold's plan at its step (5), which refuses them there, and its `--help` says the same three numbers, which it cannot import. +An adoption's assembly root on GitHub is the source's own repository, so a +leg over 100 characters named after it is mended by renaming the source on +GitHub first, as `adopt-bad-id` says of a name the policy refuses, and not by +a shorter `--project`, which plans another repository (#859's review). + The in-process runs refuse every process start (`no_process`), so a refusal that passes came before git, before `gh` and before the work directory. The end-to-end runs give the tool a `gh` that refuses a topic over 50 characters @@ -34,17 +44,21 @@ import importlib.util import os import shlex +import subprocess import sys from pathlib import Path +from types import SimpleNamespace import pytest +import test_adopt_github_exits from conftest import (ADOPT, ORG, PTY_SKIP, REPO, SCAFFOLD, WINDOWS_SKIP, git, make_source_repo, run_in_a_terminal, run_script, write_plan) from test_adopt_github_exits import (NORTHWIND, SOURCE_TREE, SPLIT_BRANCH, gh_calls, github_run, heads, needs_filter_repo) +from test_adopt_project_is_the_source import RENAME_FIRST from test_scaffold_leg_path_collisions import ( # noqa: F401 (a fixture) ProcessStarted, no_process, ) @@ -55,8 +69,8 @@ import shape_materialize # noqa: E402 from repo_shape import TOPIC_PREFIX, NamingPolicy # noqa: E402 -#: GitHub's documented limits, written out here rather than read back from -#: the module that asks them, so a change to either is a change a test sees. +#: GitHub's limits, written out here rather than read back from the module +#: that asks them, so a change to either is a change a test sees. NAME_MAX, TOPIC_MAX, OWNER_MAX = 100, 50, 39 #: The longest project name and id they leave: `-spec` adds five to a name, #: and the topic is `xf-project-`. @@ -70,6 +84,14 @@ IDENTITY = ("--org", ORG, "--elected-by", "Test Human", "--elected-on", "2026-09-02") +#: What a refusal of a name over GitHub's limit says of GitHub's numbers, at +#: the end of its remediation: the id's bound with the name's. +GITHUB_LIMITS_SAID = ( + "GitHub keeps a repository's name to at most 100 characters, so a " + "project name is at most 95, and a topic to at most 50, so the id in the " + "topic `xf-project-`, which is the project name lowercased unless " + "--id is given, is at most 39.") + def a_name(length: int) -> str: """A project name of `length` characters: one CamelCase token.""" @@ -86,7 +108,7 @@ def an_org(length: int) -> str: # --- the numbers --------------------------------------------------------------- -def test_the_limits_are_githubs_documented_numbers(): +def test_the_limits_are_githubs_numbers(): assert shape_materialize.GITHUB_NAME_MAX == NAME_MAX assert shape_materialize.GITHUB_TOPIC_MAX == TOPIC_MAX assert shape_materialize.GITHUB_OWNER_MAX == OWNER_MAX @@ -210,11 +232,7 @@ def test_a_repository_name_over_githubs_limit_is_refused_by_both_ends(): "101 characters, and GitHub keeps a repository's name to at most " "100.") assert "a disk keeps" not in caught.remediation - assert caught.remediation.endswith( - "GitHub keeps a repository's name to at most 100 characters, so a " - "project name is at most 95, and a topic to at most 50, so the id in " - "the topic `xf-project-`, which is the project name lowercased " - "unless --id is given, is at most 39.") + assert caught.remediation.endswith(GITHUB_LIMITS_SAID) def test_a_name_the_disk_cannot_hold_is_named_once_and_both_limits_said(): @@ -235,15 +253,19 @@ def test_a_name_the_disk_cannot_hold_is_named_once_and_both_limits_said(): assert "; and GitHub keeps" in caught.remediation +#: The remediation of a name the disk alone cannot hold, as it was before +#: #838 and is still. +DISK_REMEDY = ( + "Remediation: nothing was created. Use a shorter --project: the three " + "repositories are named ``, `-spec` and " + "`-code`, and a disk keeps a name of at most 255 bytes, which " + "under --local-remote-dir includes the `.git` each bare repository adds.") + + def test_the_disk_alone_keeps_its_remediation_word_for_word(): caught = refusal(shape_materialize.refuse_unstorable_names, "r", [a_name(256)], [], []) - assert caught.remediation == ( - "Remediation: nothing was created. Use a shorter --project: the " - "three repositories are named ``, `-spec` and " - "`-code`, and a disk keeps a name of at most 255 bytes, " - "which under --local-remote-dir includes the `.git` each bare " - "repository adds.") + assert caught.remediation == DISK_REMEDY def test_a_topic_over_githubs_limit_is_refused(): @@ -331,6 +353,12 @@ def test_one_past_githubs_bound_is_refused_before_any_process( assert "nothing was created" in err +#: How each of the scaffold's own refusals begins its remediation: a dry +#: run refuses what the real run would. +SCAFFOLD_NOTHING_MADE = ("Remediation: nothing was created, and `--dry-run` " + "refuses the same.") + + def test_a_derived_id_is_named_as_derived_and_mended_with_an_id( scaffold, no_process, capsys): err = refused_plan(scaffold, capsys, "--project", a_name(ID_MAX + 1)) @@ -338,9 +366,9 @@ def test_a_derived_id_is_named_as_derived_and_mended_with_an_id( f"{TOPIC_TOO_LONG}: --id (not given, so --project lowercased) is " "abbbbbbbbbbb...bbbbbbbbbbbb, 40 characters, "), err assert err.endswith( - "Pass an explicit --id of at most 39 characters: the id need not be " - "--project lowercased, and --project, which names the three " - "repositories, stays as it is.\n"), err + f"\n{SCAFFOLD_NOTHING_MADE} Pass an explicit --id of at most 39 " + "characters: the id need not be --project lowercased, and --project, " + "which names the three repositories, stays as it is.\n"), err def test_an_empty_id_is_named_as_empty(scaffold, no_process, capsys): @@ -356,7 +384,19 @@ def test_a_passed_id_is_named_as_passed(scaffold, no_process, capsys): err = refused_plan(scaffold, capsys, "--project", "Atlas", "--id", an_id(ID_MAX + 1)) assert err.startswith(f"{TOPIC_TOO_LONG}: --id is aaaa"), err - assert err.endswith("Pass a shorter --id, of at most 39 characters.\n") + assert err.endswith(f"\n{SCAFFOLD_NOTHING_MADE} Pass a shorter --id, of " + "at most 39 characters.\n"), err + + +def test_an_organisation_is_mended_as_github_spells_it(scaffold, no_process, + capsys): + err = refused_plan(scaffold, capsys, *EDGES["org"][1]) + assert err.startswith(f"{ORG_TOO_LONG}: --org is oooo"), err + assert err.endswith( + f"\n{SCAFFOLD_NOTHING_MADE} Pass --org as GitHub spells the " + "organisation, the `` of `https://github.com/`: GitHub " + "gives no user or organisation a name of more than 39 " + "characters.\n"), err def test_a_long_name_with_a_short_id_still_plans(scaffold, no_process, @@ -601,25 +641,49 @@ def assert_no_leg(github: Path, org: str, project: str) -> None: assert not (github / org / f"{project}-{leg}.git").exists(), leg -#: How a refusal says that `execute` made nothing: the name's is the disk's -#: sentence, which the scaffold shares; the other two are `execute`'s own. +#: `execute`'s own two remediations, word for word: neither is a leg's. NO_REPOSITORY = "Remediation: no repository was created." -NOTHING = "Remediation: nothing was created." +TOPIC_REMEDY = ( + f"{NO_REPOSITORY} Fix `id:` in the plan, or re-run `plan` with an " + "explicit --id, of at most 39 characters: the id need not be the " + "project's name lowercased, and the name, which the assembly root " + "already has, stays as it is.") +ORG_REMEDY = ( + f"{NO_REPOSITORY} Fix `org:` in the plan, or re-run `plan` with --org, as " + "GitHub spells the organisation, the `` of " + "`https://github.com/`: GitHub gives no user or organisation a name " + "of more than 39 characters.") + + +def renamed_first(repository: str) -> str: + """`adopt-name-too-long`'s remediation where a leg GitHub will not + create is named after the assembly root, which on GitHub is the source + itself: GitHub's numbers, then `_rename_first`'s words, as `adopt-bad-id` + says them. "Use a shorter --project" planned another repository.""" + return ("Remediation: nothing was created. The legs are named " + "`-spec` and `-code`, and --project is the " + "source's own name, which must be at most 95 characters: " + f"{GITHUB_LIMITS_SAID} {repository} {RENAME_FIRST}") + #: `(plan flags, the organisation, the project, the refusal, its -#: remediation's first sentence)`: one past each bound, each refused by -#: `execute` before a `gh` call or a leg. +#: remediation)`: one past each bound, each refused by `execute` before a +#: `gh` call or a leg. The source is named on GitHub with 96 and with 100 +#: characters, both names GitHub gives a repository. ADOPT_OVER = { "id": (("--id", an_id(ID_MAX + 1)), ORG, NORTHWIND, - "REFUSED adopt-topic-too-long: ", NO_REPOSITORY), + "REFUSED adopt-topic-too-long: ", TOPIC_REMEDY), "derived-id": (("--project", a_name(ID_MAX + 1)), ORG, a_name(ID_MAX + 1), "REFUSED adopt-topic-too-long: ", - NO_REPOSITORY), + TOPIC_REMEDY), "project": (("--project", a_name(PROJECT_MAX + 1), "--id", "atl"), ORG, a_name(PROJECT_MAX + 1), "REFUSED adopt-name-too-long: ", - NOTHING), + renamed_first(f"{ORG}/{a_name(PROJECT_MAX + 1)}")), + "project-100": (("--project", a_name(NAME_MAX), "--id", "atl"), ORG, + a_name(NAME_MAX), "REFUSED adopt-name-too-long: ", + renamed_first(f"{ORG}/{a_name(NAME_MAX)}")), "org": (("--org", an_org(OWNER_MAX + 1)), an_org(OWNER_MAX + 1), - NORTHWIND, "REFUSED adopt-org-too-long: ", NO_REPOSITORY), + NORTHWIND, "REFUSED adopt-org-too-long: ", ORG_REMEDY), } @@ -657,28 +721,117 @@ def test_executes_topic_refusal_names_the_id_by_where_it_came_from( assert caught.value.code == "adopt-topic-too-long" assert caught.value.detail.startswith( f"{path}: {named} is abbbbbbbbbbb...bbbbbbbbbbbb, 40 characters, ") - assert caught.value.remediation.startswith( - "Remediation: no repository was created. Fix `id:` in the plan") + assert caught.value.remediation == TOPIC_REMEDY + + +class _Plan: + """What `_repository_urls` reads of a plan: its `org:` and its path.""" + path = Path("adoption-plan.yaml") + + @staticmethod + def get(key, default=None): + return {"org": ORG}.get(key, default) + + +def names_refused(adopter, names: dict, local_remote_dir=None): + """`adopt-name-too-long` as `execute` refuses `names`, in process.""" + with pytest.raises(adopter.Refusal) as caught: + adopter._repository_urls( + SimpleNamespace(local_remote_dir=local_remote_dir), _Plan(), + names, None) + assert caught.value.code == "adopt-name-too-long" + return caught.value + + +def names_of(project: str, **legs) -> dict: + return {"assembly": project, "spec": legs.get("spec", f"{project}-spec"), + "code": legs.get("code", f"{project}-code")} + + +def test_a_leg_named_after_the_source_is_mended_by_renaming_it(adopter): + caught = names_refused(adopter, names_of(a_name(NAME_MAX))) + assert caught.remediation == renamed_first(f"{ORG}/{a_name(NAME_MAX)}") + + +def test_a_leg_the_plan_names_otherwise_keeps_its_remediation(adopter): + """A leg edited in the plan to a name of its own is not the source's + name: the remediation is the one it had.""" + caught = names_refused(adopter, names_of( + NORTHWIND, spec=f"{a_name(PROJECT_MAX + 1)}-spec")) + assert caught.remediation.startswith( + "Remediation: nothing was created. Use a shorter --project: ") + assert caught.remediation.endswith(GITHUB_LIMITS_SAID) + + +def test_the_disk_alone_keeps_its_remediation_under_a_local_remote_dir( + adopter, tmp_path): + """A bare `<247>-spec.git` is 256 bytes: the disk's limit, and its + remediation word for word, as before #838.""" + caught = names_refused(adopter, names_of(a_name(247)), + tmp_path / "remotes") + assert caught.remediation == DISK_REMEDY + + +class _WithoutYes: + """`subprocess` as `github_run` sees it, but `execute` is run without + `--yes`, on the stdin `github_run` gives it, which is not a terminal: + `_confirm` then refuses `adopt-unconfirmed`, as nobody can be asked.""" + + def __getattr__(self, name): + return getattr(subprocess, name) + + @staticmethod + def run(argv, *args, **kwargs): + return subprocess.run([arg for arg in argv if arg != "--yes"], *args, + **kwargs) + + +def refusals(stderr: str) -> list: + """The code of each `REFUSED` line, in order.""" + return [line.split(":", 1)[0] for line in stderr.splitlines() + if line.startswith("REFUSED ")] @WINDOWS_SKIP @needs_filter_repo -@pytest.mark.parametrize("case", ADOPT_OVER) -def test_execute_refuses_what_github_will_not_take_before_a_leg(tmp_path, - case): +@pytest.mark.parametrize("case, confirmed", [ + *(pytest.param(case, True, id=case) for case in ADOPT_OVER), + pytest.param("id", False, id="id-unconfirmed"), + pytest.param("org", False, id="org-unconfirmed"), +]) +def test_execute_refuses_what_github_will_not_take_before_a_leg( + tmp_path, monkeypatch, case, confirmed): """On main an id over 39 characters adopted, pushed both legs and the split, and ended at the topic, exit 2 (`The split IS pushed`); a leg name over 100 or an organisation over 39 ended at the first `gh repo - create`.""" + create`. Unconfirmed, without `--yes` where nobody can be asked, the + refusal is still GitHub's limit: it comes before `_confirm`.""" flags, org, project, refused, made = ADOPT_OVER[case] + if not confirmed: + monkeypatch.setattr(test_adopt_github_exits, "subprocess", + _WithoutYes()) result, github = github_run(tmp_path, "", plan_flags=flags, before_plan=a_root_named(org, project)) assert result.returncode == 2, result.stderr + result.stdout - assert refused in result.stderr, result.stderr + assert refusals(result.stderr) == [refused.rstrip(": ")], result.stderr assert gh_calls(github) == [] assert_no_leg(github, org, project) assert SPLIT_BRANCH not in heads(github / org / f"{project}.git") - assert f"\n{made} " in result.stderr, result.stderr + assert f"\n{made}\n" in result.stderr, result.stderr + + +@WINDOWS_SKIP +@needs_filter_repo +def test_without_yes_a_plan_github_takes_is_unconfirmed(tmp_path, + monkeypatch): + """The control: the run `_WithoutYes` makes reaches `_confirm`, which + refuses, so a refusal above came before it.""" + monkeypatch.setattr(test_adopt_github_exits, "subprocess", _WithoutYes()) + result, github = github_run(tmp_path, "") + assert result.returncode == 2, result.stderr + result.stdout + assert refusals(result.stderr) == ["REFUSED adopt-unconfirmed"] + assert gh_calls(github) == [] + assert_no_leg(github, ORG, NORTHWIND) #: `(plan flags, the organisation, the project)`: at each bound, adopted to From 495d4b330ac851b730d57bcef06fe62c4a5d1995 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 02:28:18 +0000 Subject: [PATCH 3/3] adopt-project.py: a root over 100 characters, which no GitHub repository can be named, keeps "Use a shorter --project", and the rename-first remedy says GitHub's 95 once, so a --project nobody compared with a source is no longer told to rename one (#838) #859's re-review of 71936633 left three NITs, all in the rename-first hunk of `_repository_urls`. NIT 3. `_a_leg_named_after_a_long_root` took any root over 95 characters. A root of 101 or more is no repository GitHub can have, so there is nothing on GitHub to rename: it comes only where the plan's `legs.assembly` is not held to a GitHub `origin` (a source with none, or a path `origin`) and a long `--project` was typed, and "Use a shorter --project" was the better advice. The predicate now also asks `len(names["assembly"]) <= GITHUB_NAME_MAX`. A root of 251, whose legs the disk cannot hold, keeps the disk's remediation. `SOURCE_NAME_TOO_LONG` said 95 twice; `GITHUB_LIMITS` says it, and the sentence before it no longer does. NIT 1. No test covered a plan where one leg is named after the source and the other was edited to a short name. `test_one_leg_named_after_the_source_is_enough` does, for each leg, and `test_a_leg_the_plan_names_otherwise_keeps_its_remediation` gains a leg that starts with the root without being `-spec`. NIT 2. `test_a_leg_named_after_the_source_is_mended_by_renaming_it` asserts the refusal's detail, which the rename-first line hands on from `refuse_unstorable_names`. `test_a_root_github_cannot_have_is_not_the_sources_to_rename` holds the roots of 101 and 251. With the root bounded, `--local-remote-dir`'s guard in the handler is reached only by a disk refusal of an edited leg beside a leg named after a source of 96 to 100; the one test of it, a root of 247, no longer got that far, so `test_a_leg_named_after_the_source_is_not_the_disks_trouble` holds it. `SHORTER_PROJECT` names the first words of the "Use a shorter --project" remediations once, and `DISK_REMEDY` is built from it with the same bytes. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- adopt-project.py | 31 ++++++++---- tests/test_github_name_limits.py | 86 ++++++++++++++++++++++++++------ 2 files changed, 91 insertions(+), 26 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 448ef65e..f2e933eb 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -10742,7 +10742,10 @@ def _repository_urls(args, plan: Plan, names: dict, # is the source's own repository on GitHub, which GitHub lets be # named with up to 100 characters, and a leg named after one of 96 # to 100 is over that limit. "Use a shorter --project" planned - # another repository, which `git ls-remote` could not read. + # another repository, which `git ls-remote` could not read. A root + # of 101 or more is no name GitHub gives a repository, so it is no + # repository's to rename, and keeps "Use a shorter --project" + # (#859's re-review, NIT 3). raise Refusal(exc.code, exc.detail, _rename_first( repositories["assembly"], SOURCE_NAME_TOO_LONG)) from None if not local: @@ -10769,21 +10772,27 @@ def _repository_urls(args, plan: Plan, names: dict, #: What `adopt-name-too-long` says before `_rename_first`'s way out, where a #: leg GitHub will not create is named after the source (#859's review, #: SHOULD 1), as `adopt-bad-id` says why the source's own name will not do. +#: `GITHUB_LIMITS` says the project name's bound, 95, so this does not. SOURCE_NAME_TOO_LONG = ( "nothing was created. The legs are named `-spec` and " - "`-code`, and --project is the source's own name, which must be " - f"at most {GITHUB_PROJECT_MAX} characters: {GITHUB_LIMITS}. ") + "`-code`, and --project is the source's own name: " + f"{GITHUB_LIMITS}. ") def _a_leg_named_after_a_long_root(names: dict) -> bool: - """Whether a leg over GitHub's `GITHUB_NAME_MAX` is named after the - assembly root, `-spec` or `-code`, as `plan` names both: - its length is then the root's, the source's own name (#859's review, - SHOULD 1). A leg the plan was edited to name otherwise is the plan's own - to shorten, and keeps the remediation it had.""" - return any(len(names[role]) > GITHUB_NAME_MAX - and names[role] == f"{names['assembly']}-{role}" - for role in ("spec", "code")) + """Whether a leg over GitHub's `GITHUB_NAME_MAX` is named after an + assembly root GitHub can have, one of at most `GITHUB_NAME_MAX` + characters. A leg is named `-spec` or `-code`, as `plan` + names both: its length is then the root's, the source's own name (#859's + review, SHOULD 1). A leg the plan was edited to name otherwise is the + plan's own to shorten, and keeps the remediation it had; so does any leg + where the root is over `GITHUB_NAME_MAX`, which no repository on GitHub + is named, so renaming the source there shortens nothing (#859's + re-review, NIT 3).""" + return len(names["assembly"]) <= GITHUB_NAME_MAX and any( + len(names[role]) > GITHUB_NAME_MAX + and names[role] == f"{names['assembly']}-{role}" + for role in ("spec", "code")) def _refuse_a_topic_github_cannot_set(plan: Plan, topic: str) -> None: diff --git a/tests/test_github_name_limits.py b/tests/test_github_name_limits.py index e9cd77b4..ca60c58b 100644 --- a/tests/test_github_name_limits.py +++ b/tests/test_github_name_limits.py @@ -29,9 +29,12 @@ `--help` says the same three numbers, which it cannot import. An adoption's assembly root on GitHub is the source's own repository, so a -leg over 100 characters named after it is mended by renaming the source on -GitHub first, as `adopt-bad-id` says of a name the policy refuses, and not by -a shorter `--project`, which plans another repository (#859's review). +leg over 100 characters named after a root of 96 to 100 is mended by +renaming the source on GitHub first, as `adopt-bad-id` says of a name the +policy refuses, and not by a shorter `--project`, which plans another +repository (#859's review). A root of 101 or more is no name GitHub gives a +repository, and a leg the plan was edited to name is the plan's own: both +keep "Use a shorter --project" (#859's re-review). The in-process runs refuse every process start (`no_process`), so a refusal that passes came before git, before `gh` and before the work directory. The @@ -253,13 +256,18 @@ def test_a_name_the_disk_cannot_hold_is_named_once_and_both_limits_said(): assert "; and GitHub keeps" in caught.remediation +#: The first words of every remediation that tells the person to pick a +#: shorter `--project`, before the limit each name broke. +SHORTER_PROJECT = ( + "Remediation: nothing was created. Use a shorter --project: the three " + "repositories are named ``, `-spec` and " + "`-code`, and ") + #: The remediation of a name the disk alone cannot hold, as it was before #: #838 and is still. DISK_REMEDY = ( - "Remediation: nothing was created. Use a shorter --project: the three " - "repositories are named ``, `-spec` and " - "`-code`, and a disk keeps a name of at most 255 bytes, which " - "under --local-remote-dir includes the `.git` each bare repository adds.") + f"{SHORTER_PROJECT}a disk keeps a name of at most 255 bytes, which under " + "--local-remote-dir includes the `.git` each bare repository adds.") def test_the_disk_alone_keeps_its_remediation_word_for_word(): @@ -662,8 +670,8 @@ def renamed_first(repository: str) -> str: says them. "Use a shorter --project" planned another repository.""" return ("Remediation: nothing was created. The legs are named " "`-spec` and `-code`, and --project is the " - "source's own name, which must be at most 95 characters: " - f"{GITHUB_LIMITS_SAID} {repository} {RENAME_FIRST}") + f"source's own name: {GITHUB_LIMITS_SAID} {repository} " + f"{RENAME_FIRST}") #: `(plan flags, the organisation, the project, the refusal, its @@ -750,19 +758,55 @@ def names_of(project: str, **legs) -> dict: def test_a_leg_named_after_the_source_is_mended_by_renaming_it(adopter): caught = names_refused(adopter, names_of(a_name(NAME_MAX))) + assert caught.detail == ( + "Abbbbbbbbbbb...bbbbbbb-spec is 105 characters, and GitHub keeps a " + "repository's name to at most 100; Abbbbbbbbbbb...bbbbbbb-code is " + "105 characters, and GitHub keeps a repository's name to at most " + "100.") + assert caught.remediation == renamed_first(f"{ORG}/{a_name(NAME_MAX)}") + + +@pytest.mark.parametrize("legs", [ + pytest.param({"code": "Other-code"}, id="spec-after-the-root"), + pytest.param({"spec": "Other-spec"}, id="code-after-the-root"), +]) +def test_one_leg_named_after_the_source_is_enough(adopter, legs): + """The other leg was edited to a short name of its own, and is not + over GitHub's limit: the leg that is, is named after the source, and + renaming the source mends it, whichever leg it is.""" + caught = names_refused(adopter, names_of(a_name(NAME_MAX), **legs)) assert caught.remediation == renamed_first(f"{ORG}/{a_name(NAME_MAX)}") -def test_a_leg_the_plan_names_otherwise_keeps_its_remediation(adopter): +@pytest.mark.parametrize("names", [ + pytest.param(names_of(NORTHWIND, spec=f"{a_name(PROJECT_MAX + 1)}-spec"), + id="named-otherwise"), + pytest.param(names_of(NORTHWIND, spec=f"{NORTHWIND}-{'s' * NAME_MAX}"), + id="starting-with-the-root"), +]) +def test_a_leg_the_plan_names_otherwise_keeps_its_remediation(adopter, + names): """A leg edited in the plan to a name of its own is not the source's - name: the remediation is the one it had.""" - caught = names_refused(adopter, names_of( - NORTHWIND, spec=f"{a_name(PROJECT_MAX + 1)}-spec")) - assert caught.remediation.startswith( - "Remediation: nothing was created. Use a shorter --project: ") + name, though it may start with it: the remediation is the one it had.""" + caught = names_refused(adopter, names) + assert caught.remediation.startswith(SHORTER_PROJECT) assert caught.remediation.endswith(GITHUB_LIMITS_SAID) +@pytest.mark.parametrize("length, remedy", [ + pytest.param(NAME_MAX + 1, SHORTER_PROJECT + GITHUB_LIMITS_SAID, id="101"), + pytest.param(251, DISK_REMEDY, id="251"), +]) +def test_a_root_github_cannot_have_is_not_the_sources_to_rename( + adopter, length, remedy): + """No repository on GitHub is named with more than 100 characters, so a + root of 101 is a `--project` no source was compared with, and "Use a + shorter --project" is the advice. At 251 the legs are 256 bytes, which a + disk cannot hold: the disk's remediation, with no word of GitHub's.""" + caught = names_refused(adopter, names_of(a_name(length))) + assert caught.remediation == remedy + + def test_the_disk_alone_keeps_its_remediation_under_a_local_remote_dir( adopter, tmp_path): """A bare `<247>-spec.git` is 256 bytes: the disk's limit, and its @@ -772,6 +816,18 @@ def test_the_disk_alone_keeps_its_remediation_under_a_local_remote_dir( assert caught.remediation == DISK_REMEDY +def test_a_leg_named_after_the_source_is_not_the_disks_trouble(adopter, + tmp_path): + """Under `--local-remote-dir` GitHub is never asked, so a `spec` leg of + 105 characters named after a source of 100 is fine, and the `code` leg + edited past the disk's limit is refused with the disk's remediation, as + any name is: renaming the source on GitHub would mend nothing.""" + caught = names_refused(adopter, names_of(a_name(NAME_MAX), + code=a_name(300)), + tmp_path / "remotes") + assert caught.remediation == DISK_REMEDY + + class _WithoutYes: """`subprocess` as `github_run` sees it, but `execute` is run without `--yes`, on the stdin `github_run` gives it, which is not a terminal: