diff --git a/adopt-project.py b/adopt-project.py index 2d12f4c1..b3d7554c 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -98,7 +98,14 @@ either leg's name, where a name GitHub only redirects (a repository renamed or moved away) is free -- a NOTE and no finding where gh cannot answer, so that `check` stays usable - offline. It prints what will happen and changes nothing. + offline. Before that, it asks what `execute` asks of the three + names, the organisation and the topic (#863): a name the disk + cannot hold, and without `--local-remote-dir` a name, an + `org:` or a topic GitHub will not take, each a finding under + `execute`'s code; after a name or an `org:` found there, whose + fix renames both legs, the legs are not asked about, and after + a topic they are. It prints what will happen and changes + nothing. execute asks about both legs before it makes either (#798), then creates the two legs, extracts them with `git filter-repo`, makes the one split commit on a branch of the source, sets the @@ -147,7 +154,11 @@ `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. +both legs and the split were pushed; and an `org:` holding a character GitHub +gives no organisation (`.`), ending in a hyphen or holding two in a row +(`adopt-bad-org`, #863). `check` finds each of these, the disk's included, +under the same code: `execute` passed what `check` had called `plan ok` +(#863). Both read one list, `_what_cannot_be_held`. 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 @@ -274,6 +285,8 @@ GITHUB_PROJECT_MAX, GITHUB_TOPIC_MAX, refuse_a_topic_github_cannot_set, refuse_an_owner_github_cannot_have, ) +# And the characters GitHub gives an organisation's name (#863). +from shape_materialize import refuse_an_owner_github_cannot_spell # noqa: E402 # 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 @@ -10616,14 +10629,34 @@ def cmd_check(args) -> int: if renamed is not None and exc.remediation == renamed: remediation.append(renamed) else: + # WHAT THE DISK OR GITHUB WILL NOT HOLD (#863): the names, the + # organisation and the topic, `execute`'s one list, which it refuses + # the first of before either leg exists, each a finding here under + # its code. Only `execute` asked them, so `check` said `plan ok` over + # a plan `execute` then refused. + held, held_fixes, held_codes = _held_findings( + plan, names, local_remote_dir, naming) + findings.extend(held) + remediation.extend(held_fixes) # LAST, as `execute` asks it last, right before it makes either leg # (#798), and only of names `execute` would get that far with: what # stands in the way of the two leg repositories, of the disk under # `--local-remote-dir` and otherwise of GitHub, with `gh repo view - # --json nameWithOwner`. + # --json nameWithOwner`. NOT ASKED AFTER A FINDING ABOVE WHOSE FIX + # RENAMES THE LEGS (`RENAMES_THE_LEGS`): a name too long is mended + # with a shorter --project, and an `org:` too long or misspelled + # with another `org:`, so `/-spec` and `-code` change, + # and the question asked now would be asked of two repositories the + # mended plan does not name. A topic finding is mended with another + # `id:`, which names no repository: the legs keep their names, the + # question is still the right one, and asked beside it, one round + # shows both (#871's review, S1), as it is asked beside the + # manifest's and the split branch's findings, which `execute` + # refuses first too. # A question gh cannot answer is a NOTE here, not a finding. - legs, fixes, unasked = _leg_remote_findings(plan, names, - local_remote_dir) + legs, fixes, unasked = ( + ([], [], []) if RENAMES_THE_LEGS.intersection(held_codes) + else _leg_remote_findings(plan, names, local_remote_dir)) findings.extend(legs) remediation.extend(fixes) notes = [*notes, *unasked] @@ -11467,6 +11500,38 @@ def _leg_remote_findings(plan: Plan, names: dict, return findings, list(dict.fromkeys(fixes)), notes +#: The codes of `_what_cannot_be_held` whose fix changes the names the two +#: leg repositories are asked under, `/-spec` and +#: `/-code` (#871's review, S1): `adopt-name-too-long` is +#: mended with a shorter --project, `adopt-org-too-long` and `adopt-bad-org` +#: with another `org:`. `check` asks #861's leg question only where none of +#: them is found. `adopt-topic-too-long` is not one: it is mended with +#: another `id:`, and an id names no repository. +RENAMES_THE_LEGS = frozenset({"adopt-name-too-long", "adopt-org-too-long", + "adopt-bad-org"}) + + +def _held_findings(plan: Plan, names: dict, local_remote_dir: Path | None, + naming: NamingPolicy + ) -> tuple[list[str], list[str], list[str]]: + """`check`'s `(findings, remediation, codes)` for what the disk or + GitHub will not hold of the three names, the organisation and the topic: + every refusal of `_what_cannot_be_held`, the list `execute` refuses the + first of (#863), each as a FINDING under its code, and the codes, which + say whether the leg question is still asked (`RENAMES_THE_LEGS`). Asked + only of an `org:` `execute` would get that far with: another is + `unsafe-value`, refused first in `_repository_urls`.""" + try: + org = checked_value("org", _plan_org(plan)) + except Refusal: + return [], [], [] + refused = list(_what_cannot_be_held(plan, names, org, + local_remote_dir is not None, naming)) + return ([f"FINDING {exc.code}: {exc.detail}" for exc in refused], + list(dict.fromkeys(exc.remediation for exc in refused)), + [exc.code for exc in refused]) + + def _make_the_remote_dir(remote_dir: Path) -> None: """`--local-remote-dir`'s `mkdir`, after the question said it can be made (`folder_in_the_way`): what fails here anyway -- a file put there @@ -11996,17 +12061,63 @@ def _plan_org(plan: Plan) -> str: return str(plan.get("org")) -def _repository_urls(args, plan: Plan, names: dict, - source: Source) -> tuple[bool, dict, dict]: +def _repository_urls(args, plan: Plan, names: dict, source: Source, + naming: NamingPolicy | None = None + ) -> tuple[bool, dict, dict]: """Where the three repositories are: `(local, repositories, urls)`. Split out of `cmd_execute` for #138. The assembly root's URL is the SOURCE itself under `--local-remote-dir`, because an in-place adoption pushes its - split branch back to the repository it read. + split branch back to the repository it read. What the disk or GitHub will + not hold of them is refused here, the first of `_what_cannot_be_held`, + the list `check` finds each of (#863). `naming` gives the topic, and is + loaded when not given. """ org = checked_value("org", _plan_org(plan)) repositories = {role: f"{org}/{name}" for role, name in names.items()} local = args.local_remote_dir is not None + refused = next(_what_cannot_be_held(plan, names, org, local, naming), None) + if refused is not None: + raise refused + return (local, repositories, + _remote_urls(org, names, args.local_remote_dir, source)) + + +def _what_cannot_be_held(plan: Plan, names: dict, org: str, local: bool, + naming: NamingPolicy | None = None): + """Each `Refusal` of what the disk or GitHub will not hold of the three + names, the organisation and the topic, in the order `execute` asks them, + one at a time: the names, and without `--local-remote-dir` the `org:`, + its length and then its characters, and the topic. + + ONE LIST, TWO READERS (#863). `execute` refuses the first one + (`_repository_urls`), before either leg exists; `check` finds every one, + under the same code (`_held_findings`). Only `execute` asked them, so a + plan `check` called `plan ok` was refused by `execute` for a name the + disk or GitHub will not take, an organisation or a topic: one list, as + `_refuse_what_check_finds` makes `check` and `execute` read one list of + the manifest's values, means the two cannot disagree again. It is a + generator, so `execute` asks nothing after the first refusal.""" + asked = [lambda: _refuse_names_nothing_keeps(names, org, local)] + if not local: + named = f"{plan.path}: `org:`" + asked += [ + lambda: refuse_an_owner_github_cannot_have( + "adopt-org-too-long", org, named, ORG_REMEDIATION), + lambda: refuse_an_owner_github_cannot_spell( + "adopt-bad-org", org, named, ORG_SPELLING_REMEDIATION), + lambda: _refuse_the_plans_topic(plan, naming)] + for ask in asked: + try: + ask() + except Refusal as exc: + yield exc + + +def _refuse_names_nothing_keeps(names: dict, org: str, local: bool) -> None: + """`adopt-name-too-long`: a name the disk or GitHub will not hold + (#261, #838), asked by `_what_cannot_be_held` for `execute` and `check` + alike (#863).""" # Each name is a directory's too: the work directory's for all three, and # 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 @@ -12032,13 +12143,7 @@ def _repository_urls(args, plan: Plan, names: dict, # 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: - 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)) + f"{org}/{names['assembly']}", SOURCE_NAME_TOO_LONG)) from None #: How `adopt-org-too-long` and `adopt-topic-too-long` are told to go on @@ -12049,6 +12154,12 @@ def _repository_urls(args, plan: Plan, names: dict, "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.") +#: How `adopt-bad-org` is told to go on (#863): `ORG_REMEDIATION`'s way out, +#: without the number, which is not what it broke. +ORG_SPELLING_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/`.") 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} " @@ -12080,13 +12191,29 @@ def _a_leg_named_after_a_long_root(names: dict) -> bool: for role in ("spec", "code")) +def _refuse_the_plans_topic(plan: Plan, + naming: NamingPolicy | None = None) -> None: + """`_refuse_a_topic_github_cannot_set` of the topic the plan's id gives, + for `_what_cannot_be_held` (#863). An id that is no project id is + `plan-bad-id`, which `Plan.project_id` refuses where it always was, and + no topic is asked of it here.""" + try: + project_id = plan.project_id + except Refusal: + return + naming = naming or NamingPolicy.load(NAMING_POLICY) + _refuse_a_topic_github_cannot_set(plan, naming.topic_for(project_id)) + + 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 + exists, with the names and the organisation (`_what_cannot_be_held`, + #863), and found by `check` alike. `_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 @@ -12966,7 +13093,8 @@ def cmd_execute(args) -> int: # every value below becomes an argument to `git` or `gh`, so each one is # validated before it is used rather than after something has gone wrong. spec_path, code_path, branch, tracking = _checked_plan_values(plan) - local, repositories, urls = _repository_urls(args, plan, names, source) + local, repositories, urls = _repository_urls(args, plan, names, source, + naming) _refuse_an_unwritable_clone_url(urls["assembly"]) paths_for = _leg_paths(plan) _refuse_what_check_finds(plan, source, args.local_remote_dir) @@ -12988,9 +13116,9 @@ def cmd_execute(args) -> int: _extracts_a_leg(paths_for)) for line in plan.seeding_record_disagreements(seeded): print(line) + # The topic GitHub will not set was refused in `_repository_urls`, with + # the names and the organisation, as `check` finds them (#863). 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) diff --git a/docs/cli.md b/docs/cli.md index 64176127..64e50575 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -888,9 +888,16 @@ named. options: -h, --help show this help message and exit - --org ORG - --family FAMILY the holder's name, ONE CamelCase token, e.g. InkRouter - --id ID lowercase family id (default: --family lowercased) + --org ORG the organisation the holder is created in; at most 39 + characters without --local-remote-dir, GitHub's limit + on an organisation's name + --family FAMILY the holder's name, ONE CamelCase token, e.g. + InkRouter, and its repository's: at most 100 + characters without --local-remote-dir, GitHub's limit + on a repository's name + --id ID lowercase family id (default: --family lowercased); at + most 39 characters without --local-remote-dir, as + GitHub keeps the topic `xf-project-` to 50 --name NAME display name (default: --family); validate-family.py holds it to the same rule --visibility {private,public,internal} diff --git a/scaffold-project.py b/scaffold-project.py index 07cca87f..c382fa87 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -51,8 +51,10 @@ 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. +`scaffold-topic-too-long` (#838). An `--org` holding a character GitHub gives +no organisation (`.`), ending in a hyphen or holding two in a row is +`scaffold-bad-org` (#863). GitHub's 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 @@ -120,6 +122,8 @@ GITHUB_TOPIC_MAX, refuse_a_topic_github_cannot_set, refuse_an_owner_github_cannot_have, ) +# And the characters GitHub gives an organisation's name (#863). +from shape_materialize import refuse_an_owner_github_cannot_spell # noqa: E402 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). @@ -876,16 +880,22 @@ def _refuse_bad_id(project_id: str, given: str | None) -> None: 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.") +#: How `scaffold-bad-org` is told to go on (#863): `ORG_REMEDIATION`'s way +#: out, without the number, which is not what it broke. +ORG_SPELLING_REMEDIATION = ( + f"{NOTHING_MADE} Pass --org as GitHub spells the organisation, the " + "`` of `https://github.com/`.") 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 - 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`. + GitHub will not set (#838); and `scaffold-bad-org`, an `--org` whose + characters GitHub gives no organisation (#863). Each is refused where + GitHub is asked, before any 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, @@ -897,6 +907,8 @@ def _refuse_what_github_cannot_hold(args, project_id: str, policy) -> None: return refuse_an_owner_github_cannot_have("scaffold-org-too-long", args.org, "--org", ORG_REMEDIATION) + refuse_an_owner_github_cannot_spell("scaffold-bad-org", args.org, + "--org", ORG_SPELLING_REMEDIATION) if args.id: named, remediation = "--id", PASSED_TOPIC_REMEDIATION else: diff --git a/scripts/family.py b/scripts/family.py index fc2b0429..e8ed8bd6 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -99,6 +99,18 @@ `gh repo view` failure (a bad login, a rate limit, no network) is refused as `remote-unreadable`. +`init` ASKS GITHUB'S LIMITS FIRST, WHERE GITHUB IS (#863), as the scaffold +and `adopt-project.py` ask theirs (#838), before `_creator_name` and before +any `gh` call, so `--dry-run` refuses the same. The holder's topic, +`xf-project-`, is set last, after the holder is created and pushed, +and GitHub keeps a topic to 50 characters, so a family id over 39 (which a +`--family` of 40 or more gives by default) is `family-topic-too-long`; an +`--org` over GitHub's 39 is `family-org-too-long`, one holding a character +GitHub gives no organisation (`.`), ending in a hyphen or holding two in a +row is `family-bad-org`, and a `--family` over GitHub's 100 for a +repository's name is `family-name-too-long`. Under `--local-remote-dir` +nothing goes to GitHub and none of them is asked. + WHAT ONLY THE WRITE ITSELF CAN ANSWER IS NOT ASKED. A member whose local changes `git checkout` would refuse to overwrite gets exit 0 and a row from `bump --dry-run`, and exit 2 from the real `bump`'s checkout; a @@ -207,6 +219,16 @@ from shape_materialize import ( # noqa: E402 free_plan_secrets_note, repository_visibility, ) +# What GitHub keeps a repository's name, a topic and an organisation's name +# to, and the characters it gives an organisation's: asked by `init` where +# GitHub is, before anything is created (#863), with the scaffold's and +# `adopt-project.py`'s own questions (#838). `_both_ends` shows a long name +# as their refusals show one. +from shape_materialize import ( # noqa: E402 + GITHUB_ID_MAX, GITHUB_NAME_MAX, GITHUB_OWNER_MAX, GITHUB_TOPIC_MAX, + _both_ends, refuse_a_topic_github_cannot_set, + refuse_an_owner_github_cannot_have, refuse_an_owner_github_cannot_spell, +) NAMING_POLICY = SHAPE_ROOT / "contracts" / "repository-naming.yaml" MANIFEST = "family.yaml" @@ -2104,6 +2126,83 @@ def _refuse_bad_family_id(family_id: str, given: str | None) -> None: DERIVED_FAMILY_ID_REMEDIATION) +#: How `family-name-too-long`, `family-org-too-long`, `family-bad-org` and +#: `family-topic-too-long` are told to go on (#863), in the scaffold's words +#: for its own (#838). A `--id` the person passed is theirs to shorten. One +#: `init` derived is `--family` lowercased, and an explicit `--id` is the way +#: round it that keeps the holder's name as it is. A `--family` too long for +#: GitHub is told the id's bound too: its id is `--family` lowercased unless +#: `--id` is given, so whoever picks a shorter name learns both numbers. +GITHUB_NOTHING_MADE = ("Remediation: nothing was created, and `--dry-run` " + "refuses the same.") +HOLDER_TOO_LONG_REMEDIATION = ( + f"{GITHUB_NOTHING_MADE} Choose a shorter --family, of at most " + f"{GITHUB_NAME_MAX} characters, GitHub's limit on a repository's name: " + "it names the holder, its repository and, unless --work-dir is given, " + f"its family folder. Its id, {FAMILY_ID_DERIVED_FROM} unless --id is " + f"given, is at most {GITHUB_ID_MAX}, as GitHub keeps the topic " + f"`xf-project-` to {GITHUB_TOPIC_MAX}.") +PASSED_TOPIC_REMEDIATION = ( + f"{GITHUB_NOTHING_MADE} Pass a shorter --id, of at most {GITHUB_ID_MAX} " + "characters.") +DERIVED_TOPIC_REMEDIATION = ( + f"{GITHUB_NOTHING_MADE} Pass an explicit --id of at most {GITHUB_ID_MAX} " + f"characters: the id need not be {FAMILY_ID_DERIVED_FROM}, and --family, " + "which names the holder, its repository and, unless --work-dir is given, " + "its family folder, stays as it is.") +ORG_REMEDIATION = ( + f"{GITHUB_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.") +ORG_SPELLING_REMEDIATION = ( + f"{GITHUB_NOTHING_MADE} Pass --org as GitHub spells the organisation, the " + "`` of `https://github.com/`.") + + +def _refuse_what_github_cannot_hold(args, family: str, family_id: str, + topic: str) -> None: + """What GitHub will not take of the holder, refused where GitHub is + asked (#863), before anything exists and under `--dry-run` alike: + `family-name-too-long`, a `--family` over GitHub's 100 for the holder's + repository, `/`; `family-org-too-long` and + `family-bad-org`, an `--org` GitHub gives no organisation, by its length + or its characters; and `family-topic-too-long`, an id whose + `xf-project-` topic GitHub will not set. The questions are the + scaffold's (#838), asked with its helpers. + + THE TOPIC IS SET LAST (`_finish_holder_remote`'s `gh repo edit + --add-topic`), after `_create_holder_remote` and + `_create_holder_worktree`'s push, so an id over 39 characters, which a + `--family` of 40 or more gives by default, failed there with the holder + made and pushed, no NEXT STEPS printed, and a re-run refused as + `family-remote-exists`. An `--org` or a `--family` GitHub will not take + failed at `gh repo create`. Under `--local-remote-dir` none is asked: a + bare repository on disk has no topic and no owner, and its name is the + disk's. The id is named as `_refuse_bad_family_id` names it, by where it + came from.""" + if args.local_remote_dir is not None: + return + if len(family) > GITHUB_NAME_MAX: + raise Refusal( + "family-name-too-long", + f"--family is {_both_ends(family)}, {len(family)} characters, and " + "GitHub keeps a repository's name, the holder's " + f"`/`, to at most {GITHUB_NAME_MAX}", + HOLDER_TOO_LONG_REMEDIATION) + refuse_an_owner_github_cannot_have("family-org-too-long", args.org, + "--org", ORG_REMEDIATION) + refuse_an_owner_github_cannot_spell("family-bad-org", args.org, "--org", + ORG_SPELLING_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 {FAMILY_ID_DERIVED_FROM})" + remediation = DERIVED_TOPIC_REMEDIATION + refuse_a_topic_github_cannot_set("family-topic-too-long", topic, + family_id, named, remediation) + + def _checked_family_identity(args) -> tuple[str, str, str]: """`(family, family_id, topic)` — every name check `init` makes before it decides where anything lands. @@ -2130,6 +2229,9 @@ def _checked_family_identity(args) -> tuple[str, str, str]: _classify_family_name(policy, family) _refuse_an_unholdable_holder_name(family) topic = policy.topic_for(family_id) + # WHAT GITHUB WILL NOT TAKE (#863), before `_creator_name` and before any + # `gh` call: the topic is set last, after the holder is pushed. + _refuse_what_github_cannot_hold(args, family, family_id, topic) return family, family_id, topic @@ -9491,13 +9593,25 @@ def build_parser() -> argparse.ArgumentParser: "write is put through the holder's own YAML reader and " "its own validate-family.py: a value either would not " "hold is refused and named.") - init.add_argument("--org", required=True) + # GITHUB'S LIMITS, said where each value is typed (#863), as the + # scaffold says its own (#838), and asked only without + # `--local-remote-dir`, as the plan asks them. + init.add_argument("--org", required=True, + help="the organisation the holder is created in; at " + f"most {GITHUB_OWNER_MAX} characters without " + "--local-remote-dir, GitHub's limit on an " + "organisation's name") init.add_argument("--family", required=True, help="the holder's name, ONE CamelCase token, e.g. " - "InkRouter") + "InkRouter, and its repository's: at most " + f"{GITHUB_NAME_MAX} characters without " + "--local-remote-dir, GitHub's limit on a " + "repository's name") init.add_argument("--id", default=None, help="lowercase family id (default: --family " - "lowercased)") + f"lowercased); at most {GITHUB_ID_MAX} characters " + "without --local-remote-dir, as GitHub keeps the " + f"topic `xf-project-` to {GITHUB_TOPIC_MAX}") init.add_argument("--name", default=None, help="display name (default: --family); " "validate-family.py holds it to the same rule") diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index a7dd3220..922f791a 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -5373,3 +5373,63 @@ def refuse_an_owner_github_cannot_have(refusal: str, owner: str, named: str, "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) + + +# An organisation's characters, as GitHub gives them (#863). `checked_value` +# admits `SAFE_ARG_RE`'s whole alphabet, and the manifest validator's +# `QUALIFIED_RE` takes `_` and `.` anywhere after an owner's first character, +# so `--org my_org.x`, `--org trail-` and `--org a--b` passed every plan and +# went on to `gh repo create my_org.x/`. GitHub's documented rule for a +# user's or an organisation's name is ASCII letters, digits and single +# hyphens, with no hyphen at either end; it is not measured here. A leading +# hyphen is `checked_value`'s refusal already, so the rule left to ask is a +# character outside it, a trailing hyphen and two hyphens in a row. `_` IS +# LET BY: a GitHub Enterprise Managed User's login holds one +# (`_`), so a rule that refuses it may refuse a personal +# namespace that exists, and refusing it waits on a probe against a real +# account. The rule is asked by the tools' plans, beside the 39, and is not +# put in the pinned `scripts/repo_shape.py` or the two validators, so no +# project is re-pinned and a manifest that already names such an owner still +# reads as it did. + +#: The characters this asks of an owner's name: GitHub's, and `_` (above). +GITHUB_OWNER_CHARACTERS = re.compile(r"[A-Za-z0-9_-]") + + +def refuse_an_owner_github_cannot_spell(refusal: str, owner: str, named: str, + remediation: str) -> None: + """Raise `Refusal(refusal)` when `owner`, the organisation the run + creates repositories in, holds a character GitHub gives no user's or + organisation's name, ends in a hyphen or holds two hyphens in a row + (#863): 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, beside `refuse_an_owner_github_cannot_have`: under + `--local-remote-dir` the organisation names no account.""" + held = "".join(dict.fromkeys( + char for char in owner if not GITHUB_OWNER_CHARACTERS.fullmatch(char))) + problems = [] + if held: + shown = [f"`{char}`" for char in held] + problems.append("holding " + ( + f"{', '.join(shown[:-1])} or {shown[-1]}" if len(shown) > 1 + else shown[0])) + if owner.endswith("-"): + problems.append("ending in a hyphen") + if "--" in owner: + problems.append("with two hyphens in a row") + if not problems: + return + # A VALUE PAST THE LENGTH BOUND IS SHOWN CUT, as + # `refuse_an_owner_github_cannot_have` shows it (#871's review, N3): + # `check` lists this refusal beside that one, which cuts it, and this + # one printed all of a 255-character `org:`. `_both_ends` keeps the + # tail, where a trailing hyphen is, and the problems above name every + # character, wherever the cut took it from. + shown = (repr(owner) if len(owner) <= GITHUB_OWNER_MAX + else f"{_both_ends(owner)}, {len(owner)} characters") + raise Refusal( + refusal, + f"{named} is {shown}, and GitHub gives no user or organisation a " + f"name {', or '.join(problems)}, so no repository can be created " + "under it", + remediation) diff --git a/tests/test_adopt_manifest_values.py b/tests/test_adopt_manifest_values.py index 6d4b70bc..f79beb5c 100644 --- a/tests/test_adopt_manifest_values.py +++ b/tests/test_adopt_manifest_values.py @@ -207,6 +207,10 @@ def test_plan_says_it_in_the_validators_own_words(tmp_path): def test_plan_still_writes_a_plan_for_values_the_validator_accepts(tmp_path): + """`_` and `.` are in the validator's `QUALIFIED_RE`, and it still + reads `my_org.x` as it did: no `plan-manifest-invalid`. GitHub gives no + organisation a `.`, so without `--local-remote-dir`, where GitHub is + asked, `check` finds `adopt-bad-org`, as `execute` refuses it (#863).""" source = make_source_repo(tmp_path / "Thing") out = tmp_path / "plan.yaml" result = write_plan(source, out, org="my_org.x", @@ -214,8 +218,9 @@ def test_plan_still_writes_a_plan_for_values_the_validator_accepts(tmp_path): "--reference=docs/project-repo-schema.md")) assert result.returncode == 0, result.stderr + result.stdout checked = run_script(ADOPT, "check", "--plan", str(out)) - assert checked.returncode in (0, 1), checked.stderr + assert checked.returncode == 1, checked.stderr assert FINDING not in checked.stderr + assert "FINDING adopt-bad-org: " in checked.stderr @pytest.mark.parametrize("length", [251, 255]) @@ -369,19 +374,23 @@ def test_check_and_execute_read_one_list(adopter, world): def test_execute_refuses_a_name_whose_bare_repository_cannot_be_made( tmp_path, length): """`plan` cannot know `--local-remote-dir`, so a 247 to 250 character - project plans and checks; `execute` died in `Path.exists` with `OSError: - File name too long` and a traceback. It is now refused before a leg.""" + project plans; `execute` died in `Path.exists` with `OSError: File name + too long` and a traceback. It is now refused before a leg. `check`, + given the flags `execute` is given, finds it under the same code (#863), + where it said `plan ok`.""" source = make_source_repo(tmp_path / "Thing") plan = tmp_path / "plan.yaml" written = write_plan(source, plan, project="N" * length) assert written.returncode == 0, written.stderr + written.stdout for path, leg in ANSWERS: resolve(plan, path, leg) - checked = run_script(ADOPT, "check", "--plan", str(plan)) - assert checked.returncode == 0, checked.stderr + checked.stdout + flags = ("--local-remote-dir", str(tmp_path / REMOTES), "--work-dir", + str(tmp_path / WORK)) + checked = run_script(ADOPT, "check", "--plan", str(plan), *flags) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert "FINDING adopt-name-too-long: " in checked.stderr result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", - "--local-remote-dir", str(tmp_path / REMOTES), - "--work-dir", str(tmp_path / WORK)) + *flags) assert result.returncode == 2, result.stderr + result.stdout assert "REFUSED adopt-name-too-long" in result.stderr assert "Traceback" not in result.stderr diff --git a/tests/test_github_limits_init_check_org.py b/tests/test_github_limits_init_check_org.py new file mode 100644 index 00000000..ec19c068 --- /dev/null +++ b/tests/test_github_limits_init_check_org.py @@ -0,0 +1,915 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`family.py init`, `adopt-project.py check` and every tool's `--org`: what +GitHub will not take is refused before anything exists (#863). + +#838 (PR #859) gave `scaffold-project.py`, `adopt-project.py execute` and, +through the scaffold's dry run, `setup-project.py` GitHub's three limits: 100 +characters for a repository's name, 50 for a topic and 39 for a user's or an +organisation's name. It left out three places, which #863 names: + +* `family.py init` set the holder's topic, `xf-project-`, LAST, + with `gh repo edit --add-topic` after `gh repo create` and the push, and + asked neither the 50, nor the 39 of `--org`, nor the 100 of the holder's + name. A family of 40 characters made and pushed its holder and then met + GitHub's refusal: exit 2, no NEXT STEPS, and a re-run refused as + `family-remote-exists`. +* `adopt-project.py check` asked none of the limits `execute` asks, the + disk's 255 bytes included, so it said `plan ok` over a plan `execute` then + refused as `adopt-name-too-long`, `adopt-org-too-long` or + `adopt-topic-too-long`. +* An `--org` holding a character GitHub gives no organisation (`.`), ending + in a hyphen or holding two in a row passed every plan and went on to `gh + repo create`. + +Now `init` refuses `family-name-too-long`, `family-org-too-long`, +`family-bad-org` and `family-topic-too-long` where GitHub is asked, before +`_creator_name` and before any `gh` call, under `--dry-run` alike. `check` +finds what `execute` refuses, from the one list both read +(`_what_cannot_be_held`), under the same code. The scaffold, `check`, +`execute` and `init` refuse such an `--org` as `scaffold-bad-org`, +`adopt-bad-org` and `family-bad-org`, and `setup-project.py` at its step (5), +the scaffold's plan. `_` is let by: a GitHub Enterprise Managed User's login +holds one, and refusing it waits on a probe against a real account. None of +GitHub's limits is asked under `--local-remote-dir`, where nothing goes to +GitHub. + +THE NUMBERS AND THE CHARACTERS ARE GITHUB'S DOCUMENTATION, NOT MEASURED: no +run here creates a real repository. The fake `gh` below refuses as that +documentation says (a topic over 50, an owner over 39 or holding another +character, a repository name over 100) and answers `repo view` as not found; +the runs that refuse assert that it was never called. The in-process runs +refuse every process start but the one they allow, so a refusal that passes +came before it. The end-to-end runs are POSIX only: their `gh` is a `#!` +script. +""" + +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import test_adopt_github_exits +from conftest import ADOPT, ORG, REPO, WINDOWS_SKIP, resolve, run_script +from conftest import make_source_repo, write_plan +from test_adopt_github_exits import (NORTHWIND, gh_calls, github_run, + needs_filter_repo) +from test_adopt_manifest_values import ANSWERS +from test_github_name_limits import ( # noqa: F401 (`scaffold`: a fixture) + a_name, a_root_named, an_id, an_org, goes_on, help_entry, refusals, + refused_plan, run_entry, scaffold, setup_env, setup_flags) +from test_scaffold_leg_path_collisions import ( # noqa: F401 (a fixture) + ProcessStarted, no_process, +) + +sys.path.insert(0, str(REPO / "scripts")) +import shape_materialize # noqa: E402 +from repo_shape import NamingPolicy, Refusal # noqa: E402 + +FAMILY = REPO / "scripts" / "family.py" + +#: GitHub's numbers, written out as #859's tests write them. +NAME_MAX, TOPIC_MAX, OWNER_MAX = 100, 50, 39 +ID_MAX = TOPIC_MAX - len("xf-project-") + +#: What each refusal is called. +FAMILY_NAME_TOO_LONG = "REFUSED family-name-too-long" +FAMILY_TOPIC_TOO_LONG = "REFUSED family-topic-too-long" +FAMILY_ORG_TOO_LONG = "REFUSED family-org-too-long" +FAMILY_BAD_ORG = "REFUSED family-bad-org" +SCAFFOLD_BAD_ORG = "REFUSED scaffold-bad-org" +SCAFFOLD_ORG_TOO_LONG = "REFUSED scaffold-org-too-long" + +#: An `--org` GitHub gives no organisation, by its characters, each with what +#: the refusal says of it. A leading hyphen is not here: `checked_value` +#: refuses it first, as `unsafe-value`. +MISSPELLED = { + "dot": ("my.org", "holding `.`"), + "underscore-and-dot": ("my_org.x", "holding `.`"), + "trailing-hyphen": ("trail-", "ending in a hyphen"), + "two-hyphens": ("a--b", "with two hyphens in a row"), +} +#: The controls: an organisation GitHub spells, and one with `_`, which is +#: let by until a real account says otherwise. +SPELLED = {"hyphen": "ok-org", "underscore": "ok_org"} + + +def misspelled(owner: str, named: str = "--org") -> str: + """What a refusal of `owner`'s characters says, after the code.""" + return (f"{named} is {owner!r}, and GitHub gives no user or organisation " + f"a name {dict(MISSPELLED.values())[owner]}, so no repository can " + "be created under it") + + +# --- the one helper the tools ask ------------------------------------------- + +@pytest.mark.parametrize("owner", [*SPELLED.values(), "a", "A0-b1-C2", + an_org(OWNER_MAX)]) +def test_an_owner_github_spells_is_not_refused(owner): + assert shape_materialize.refuse_an_owner_github_cannot_spell( + "r", owner, "--org", "Remedy.") is None + + +@pytest.mark.parametrize("case", MISSPELLED) +def test_an_owner_github_does_not_spell_is_refused_by_name(case): + owner, _ = MISSPELLED[case] + with pytest.raises(Refusal) as caught: + shape_materialize.refuse_an_owner_github_cannot_spell( + "r", owner, "--org", "Remedy.") + assert (caught.value.code, caught.value.detail, + caught.value.remediation) == ("r", misspelled(owner), "Remedy.") + + +def test_every_character_and_every_hyphen_rule_broken_is_named_once(): + with pytest.raises(Refusal) as caught: + shape_materialize.refuse_an_owner_github_cannot_spell( + "r", "a.b@c+d~e/f.g--h-", "`org:`", "Remedy.") + assert caught.value.detail == ( + "`org:` is 'a.b@c+d~e/f.g--h-', and GitHub gives no user or " + "organisation a name holding `.`, `@`, `+`, `~` or `/`, or ending in " + "a hyphen, or with two hyphens in a row, so no repository can be " + "created under it") + + +def test_an_owner_past_the_length_bound_is_shown_cut_as_its_length_is(): + """#871's review, N3: a misspelled value past GitHub's 39 is shown as + `refuse_an_owner_github_cannot_have` shows it, cut in the middle and + with its length, not all 255 characters of it. One at the bound is + still shown whole.""" + longest = an_org(254) + "." + with pytest.raises(Refusal) as caught: + shape_materialize.refuse_an_owner_github_cannot_spell( + "r", longest, "`org:`", "Remedy.") + assert caught.value.detail == ( + "`org:` is oooooooooooo...ooooooooooo., 255 characters, and GitHub " + "gives no user or organisation a name holding `.`, so no repository " + "can be created under it") + at_the_bound = an_org(OWNER_MAX - 1) + "-" + with pytest.raises(Refusal) as caught: + shape_materialize.refuse_an_owner_github_cannot_spell( + "r", at_the_bound, "`org:`", "Remedy.") + assert caught.value.detail.startswith(f"`org:` is {at_the_bound!r}, and " + "GitHub gives no user"), \ + caught.value.detail + + +# --- family.py init, in process: before `_creator_name` and any `gh` -------- + +@pytest.fixture(scope="module") +def family(): + spec = importlib.util.spec_from_file_location("family_github_limits", + FAMILY) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def only_the_branch_question(monkeypatch): + """Every process `init` starts goes through `subprocess.Popen`. While + this holds, only `git check-ref-format` may start, the question + `refuse_bad_branch` asks of `--tracking-branch` before any name is + judged; any other process fails the test and names the command. With no + `--created-by` and no GIT_AUTHOR_NAME, `_creator_name` starts `git + config user.name`, so a run that passes the limits fails there.""" + real = subprocess.Popen + + def guarded(args, *rest, **kwargs): + if "check-ref-format" in [str(arg) for arg in args]: + return real(args, *rest, **kwargs) + raise ProcessStarted(f"a process was started: {args!r}") + + monkeypatch.delenv("GIT_AUTHOR_NAME", raising=False) + monkeypatch.setattr(subprocess, "Popen", guarded) + + +def init_args(tmp_path: Path, *flags: str, dry_run: bool = True) -> list: + """`init` of `Orbit` in `testorg`, then `flags`, which win: argparse + keeps the last of a flag given twice.""" + return ["init", "--org", ORG, "--family", "Orbit", "--work-dir", + str(tmp_path / "work"), *flags, + *(["--dry-run"] if dry_run else [])] + + +def refused_init(family, capsys, tmp_path: Path, *flags: str, + dry_run: bool = True) -> str: + """`init` refuses with `flags` and prints no plan. Returns stderr.""" + code = family.main(init_args(tmp_path, *flags, dry_run=dry_run)) + out, err = capsys.readouterr() + assert (code, out) == (2, ""), out + err + assert not (tmp_path / "work").exists() + return err + + +def init_goes_on(family, capsys, tmp_path: Path, *flags: str) -> None: + """`init` with `flags` passes every question GitHub's limits ask and + goes on to `_creator_name`, whose `git config user.name` is refused.""" + argv = init_args(tmp_path, *flags) + with pytest.raises(ProcessStarted, match="user.name"): + family.main(argv) + err = capsys.readouterr().err + assert "too-long" not in err, err + assert "bad-org" not in err, err + + +#: `(flags at the bound, flags one past it, the refusal)`. +INIT_EDGES = { + "derived-id": (("--family", a_name(ID_MAX)), + ("--family", a_name(ID_MAX + 1)), FAMILY_TOPIC_TOO_LONG), + "id": (("--id", an_id(ID_MAX)), ("--id", an_id(ID_MAX + 1)), + FAMILY_TOPIC_TOO_LONG), + "name": (("--family", a_name(NAME_MAX), "--id", "orbit"), + ("--family", a_name(NAME_MAX + 1), "--id", "orbit"), + FAMILY_NAME_TOO_LONG), + "org": (("--org", an_org(OWNER_MAX)), ("--org", an_org(OWNER_MAX + 1)), + FAMILY_ORG_TOO_LONG), +} + + +@pytest.mark.parametrize("edge", INIT_EDGES) +def test_init_at_githubs_bound_goes_on(family, only_the_branch_question, + capsys, tmp_path, edge): + init_goes_on(family, capsys, tmp_path, *INIT_EDGES[edge][0]) + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real"]) +@pytest.mark.parametrize("edge", INIT_EDGES) +def test_init_one_past_githubs_bound_is_refused_before_any_process( + family, only_the_branch_question, capsys, tmp_path, edge, dry_run): + _, over, code = INIT_EDGES[edge] + err = refused_init(family, capsys, tmp_path, *over, dry_run=dry_run) + assert err.startswith(f"{code}: "), err + assert "\nRemediation: nothing was created, and `--dry-run` refuses the " \ + "same. " in err, err + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real"]) +@pytest.mark.parametrize("case", MISSPELLED) +def test_init_refuses_an_org_github_does_not_spell( + family, only_the_branch_question, capsys, tmp_path, case, dry_run): + owner, _ = MISSPELLED[case] + err = refused_init(family, capsys, tmp_path, "--org", owner, + dry_run=dry_run) + assert err == ( + f"{FAMILY_BAD_ORG}: {misspelled(owner)}\nRemediation: nothing was " + "created, and `--dry-run` refuses the same. Pass --org as GitHub " + "spells the organisation, the `` of " + "`https://github.com/`.\n") + + +@pytest.mark.parametrize("case", SPELLED) +def test_init_takes_an_org_github_spells(family, only_the_branch_question, + capsys, tmp_path, case): + init_goes_on(family, capsys, tmp_path, "--org", SPELLED[case]) + + +#: How each of `init`'s own GitHub refusals begins its way out. +INIT_NOTHING_MADE = ("Remediation: nothing was created, and `--dry-run` " + "refuses the same.") + + +def test_init_names_a_derived_id_as_derived_and_mends_it_with_an_id( + family, only_the_branch_question, capsys, tmp_path): + err = refused_init(family, capsys, tmp_path, "--family", + a_name(ID_MAX + 1)) + assert err == ( + f"{FAMILY_TOPIC_TOO_LONG}: --id (not given, so --family lowercased) " + "is abbbbbbbbbbb...bbbbbbbbbbbb, 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\n" + f"{INIT_NOTHING_MADE} Pass an explicit --id of at most 39 characters: " + "the id need not be --family lowercased, and --family, which names " + "the holder, its repository and, unless --work-dir is given, its " + "family folder, stays as it is.\n") + + +def test_init_names_an_empty_id_as_empty(family, only_the_branch_question, + capsys, tmp_path): + err = refused_init(family, capsys, tmp_path, "--family", + a_name(ID_MAX + 1), "--id", "") + assert err.startswith(f"{FAMILY_TOPIC_TOO_LONG}: --id (empty, so --family " + "lowercased) is "), err + + +def test_init_names_a_passed_id_as_passed(family, only_the_branch_question, + capsys, tmp_path): + err = refused_init(family, capsys, tmp_path, "--id", an_id(ID_MAX + 1)) + assert err.startswith(f"{FAMILY_TOPIC_TOO_LONG}: --id is aaaa"), err + assert err.endswith(f"\n{INIT_NOTHING_MADE} Pass a shorter --id, of at " + "most 39 characters.\n"), err + + +def test_init_mends_a_long_org_as_github_spells_it( + family, only_the_branch_question, capsys, tmp_path): + err = refused_init(family, capsys, tmp_path, *INIT_EDGES["org"][1]) + assert err == ( + f"{FAMILY_ORG_TOO_LONG}: --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\n" + f"{INIT_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") + + +def test_init_names_a_long_family_and_both_numbers( + family, only_the_branch_question, capsys, tmp_path): + """The id is `--family` lowercased unless `--id` is given, so a shorter + name is told the id's bound too, as #859's GitHub remediation says it.""" + err = refused_init(family, capsys, tmp_path, *INIT_EDGES["name"][1]) + assert err == ( + f"{FAMILY_NAME_TOO_LONG}: --family is Abbbbbbbbbbb...bbbbbbbbbbbb, " + "101 characters, and GitHub keeps a repository's name, the holder's " + "`/`, to at most 100\n" + f"{INIT_NOTHING_MADE} Choose a shorter --family, of at most 100 " + "characters, GitHub's limit on a repository's name: it names the " + "holder, its repository and, unless --work-dir is given, its family " + "folder. Its id, --family lowercased unless --id is given, is at most " + "39, as GitHub keeps the topic `xf-project-` to 50.\n") + + +def test_init_keeps_a_long_family_with_a_short_id( + family, only_the_branch_question, capsys, tmp_path): + """A family of 40 to 100 characters keeps its name: a shorter `--id` is + the way round the topic's bound.""" + for length in (ID_MAX + 1, 70, NAME_MAX): + init_goes_on(family, capsys, tmp_path, "--family", a_name(length), + "--id", "orb") + + +@pytest.mark.parametrize("org", [an_org(OWNER_MAX + 1), "my.org", "trail-"]) +def test_init_asks_none_of_them_of_a_local_run( + family, only_the_branch_question, capsys, tmp_path, org): + """Under `--local-remote-dir` nothing goes to GitHub: no topic is set, + the organisation names no account, and the holder's name is the disk's. + A family of 101 characters, whose derived id is 101 too, goes on.""" + init_goes_on(family, capsys, tmp_path, "--family", a_name(NAME_MAX + 1), + "--org", org, "--local-remote-dir", str(tmp_path / "remotes")) + assert not (tmp_path / "remotes").exists() + + +#: What `init --help` says of each bound, by the flag, in the scaffold's +#: words for its own (#838). +INIT_SAID = { + "--org ORG": ( + f"at most {OWNER_MAX} characters without --local-remote-dir, GitHub's " + "limit on an organisation's name"), + "--family FAMILY": ( + f"at most {NAME_MAX} characters without --local-remote-dir, GitHub's " + "limit on a repository's name"), + "--id ID": ( + f"at most {ID_MAX} characters without --local-remote-dir, as GitHub " + f"keeps the topic `xf-project-` to {TOPIC_MAX}"), +} + + +def test_init_help_says_each_bound_where_the_value_is_typed(): + result = run_script(FAMILY, "init", "--help") + assert result.returncode == 0, result.stderr + for flag, sentence in INIT_SAID.items(): + entry = help_entry(result.stdout, flag) + assert sentence in entry, (flag, entry) + + +# --- family.py init end to end: GitHub's refusal, by its documentation ------ + +#: A `gh` with no network that answers as GitHub's documentation says: a +#: repository it has not got is not found; `repo create` refuses an owner +#: over 39 characters or holding a character GitHub gives no account (`_` +#: aside, as the tools let it by), and a name over 100; `repo edit +#: --add-topic` refuses a topic over 50. Every call is logged. +FAKE_GH = b"""#!/bin/sh +printf '%s\\n' "$*" >> "$FAKE_GH_LOG" +case "$1 $2" in + "repo view") + said="GraphQL: Could not resolve to a Repository" + echo "$said with the name '$3'. (repository)" >&2 + exit 1 ;; + "repo create") + owner=${3%%/*}; name=${3#*/} + case "$owner" in + *[!A-Za-z0-9_-]*|*-|*--*) + echo "HTTP 422: owner is not a GitHub account name" >&2; exit 1 ;; + esac + if [ "${#owner}" -gt 39 ] || [ "${#name}" -gt 100 ]; then + echo "HTTP 422: name is too long (maximum is 100 characters)" >&2 + exit 1 + fi + 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 init_on_github(tmp_path: Path, *flags: str, dry_run: bool = False): + """A real `init` of `Orbit` against `FAKE_GH`, pushing nothing. Public, + so no `gh` call asks the organisation's plan.""" + env, log = fake_github(tmp_path) + result = run_script(FAMILY, "init", "--org", ORG, "--family", "Orbit", + "--created-by", "Test Human", "--visibility", + "public", "--no-push", "--work-dir", + str(tmp_path / "work"), *flags, + *(["--dry-run"] if dry_run else []), env=env) + return result, log + + +#: `(flags, the refusal)`: one past each bound, and a character. +INIT_ON_GITHUB = { + "derived-id": (("--family", a_name(ID_MAX + 1)), FAMILY_TOPIC_TOO_LONG), + "name": (("--family", a_name(NAME_MAX + 1), "--id", "orbit"), + FAMILY_NAME_TOO_LONG), + "org": (("--org", an_org(OWNER_MAX + 1)), FAMILY_ORG_TOO_LONG), + "org-dot": (("--org", "my_org.x"), FAMILY_BAD_ORG), +} + + +@WINDOWS_SKIP +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real"]) +@pytest.mark.parametrize("case", INIT_ON_GITHUB) +def test_init_refuses_before_gh_is_asked_anything(tmp_path, case, dry_run): + """On main a family of 40 characters created and seeded its holder, + then GitHub refused `--add-topic`: exit 2 with the holder made and no + NEXT STEPS. A long `--org` or family name, or `my_org.x`, reached `gh + repo create`, and each dry run exited 0. Now nothing reaches `gh`.""" + flags, refused = INIT_ON_GITHUB[case] + result, log = init_on_github(tmp_path, *flags, dry_run=dry_run) + assert result.returncode == 2, result.stderr + result.stdout + assert result.stderr.startswith(f"{refused}: "), result.stderr + assert result.stdout == "" + assert not log.exists(), log.read_text(encoding="utf-8") + assert not (tmp_path / "work").exists() + + +@WINDOWS_SKIP +def test_init_sets_a_topic_at_githubs_bound(tmp_path): + name = a_name(ID_MAX) + result, log = init_on_github(tmp_path, "--family", name) + assert result.returncode == 0, result.stderr + result.stdout + calls = log.read_text(encoding="utf-8").splitlines() + assert f"repo edit {ORG}/{name} --add-topic xf-project-{name.lower()}" in \ + calls, calls + assert "NEXT STEPS" in result.stdout + + +# --- scaffold-project.py and setup-project.py: the characters --------------- + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real"]) +@pytest.mark.parametrize("case", MISSPELLED) +def test_the_scaffold_refuses_an_org_github_does_not_spell( + scaffold, no_process, capsys, case, dry_run): + owner, _ = MISSPELLED[case] + err = refused_plan(scaffold, capsys, "--project", "Atlas", "--org", owner, + dry_run=dry_run) + assert err == ( + f"{SCAFFOLD_BAD_ORG}: {misspelled(owner)}\nRemediation: nothing was " + "created, and `--dry-run` refuses the same. Pass --org as GitHub " + "spells the organisation, the `` of " + "`https://github.com/`.\n") + + +@pytest.mark.parametrize("case", SPELLED) +def test_the_scaffold_takes_an_org_github_spells(scaffold, no_process, capsys, + case): + goes_on(scaffold, capsys, "--project", "Atlas", "--org", SPELLED[case]) + + +def test_the_scaffold_asks_no_characters_of_a_local_run(scaffold, no_process, + capsys, tmp_path): + goes_on(scaffold, capsys, "--project", "Atlas", "--org", "my_org.x", + "--local-remote-dir", str(tmp_path / "remotes")) + assert not (tmp_path / "remotes").exists() + + +#: `(the organisation, the refusal or None)`: setup's step (5) is the +#: scaffold's plan, which refuses it there. +SETUP_ORGS = { + "dot": ("my.org", SCAFFOLD_BAD_ORG), + "trailing-hyphen": ("trail-", SCAFFOLD_BAD_ORG), + "underscore": ("ok_org", None), +} + + +@WINDOWS_SKIP +@pytest.mark.parametrize("case", SETUP_ORGS) +def test_setup_refuses_at_the_plan_an_org_github_does_not_spell(tmp_path, + case): + org, refused = SETUP_ORGS[case] + result = run_entry(*setup_flags(tmp_path, "--project", "Atlas", "--org", + org), 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 "bad-org" not in result.stderr + return + assert result.returncode == 2, result.stderr + result.stdout + assert f"{refused}: --org is {org!r}" in result.stderr, result.stderr + assert "REFUSED: the plan could not be produced (see above)." in \ + result.stderr + + +# --- adopt-project.py: `check` and `execute` read one list ------------------ + +@pytest.fixture(scope="module") +def adopter(): + spec = importlib.util.spec_from_file_location("adopt_github_limits_863", + ADOPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class _Plan: + """What the list reads of a plan: its path, its keys and its id.""" + path = Path("adoption-plan.yaml") + + def __init__(self, org: str = ORG, project_id: str = "northwind"): + self.data = {"org": org, "id": project_id} + + def get(self, key, default=None): + return self.data.get(key, default) + + @property + def project_id(self) -> str: + return self.data["id"] + + +def names_of(project: str) -> dict: + return {"assembly": project, "spec": f"{project}-spec", + "code": f"{project}-code"} + + +def test_check_and_execute_read_one_list(adopter, monkeypatch): + """`execute` refuses the first refusal `_what_cannot_be_held` gives and + `check` finds each one: a list of `check`'s own would be a second list + to forget a question in.""" + first = Refusal("first-code", "the first detail", "Remediation: one.") + second = Refusal("second-code", "the second detail", "Remediation: two.") + asked = [] + + def stand_in(*args): + asked.append(args) + yield first + yield second + + monkeypatch.setattr(adopter, "_what_cannot_be_held", stand_in) + run_args, plan, names = (SimpleNamespace(local_remote_dir=None), _Plan(), + names_of(NORTHWIND)) + with pytest.raises(Refusal) as caught: + adopter._repository_urls(run_args, plan, names, None) + assert caught.value is first + naming = NamingPolicy.load(REPO / "contracts" / "repository-naming.yaml") + assert adopter._held_findings(_Plan(), names_of(NORTHWIND), None, + naming) == ( + ["FINDING first-code: the first detail", + "FINDING second-code: the second detail"], + ["Remediation: one.", "Remediation: two."], + ["first-code", "second-code"]) + assert len(asked) == 2 + + +def test_the_list_holds_each_refusal_in_executes_order(adopter): + """A name, the organisation and the id, all three over: `check` finds + the three, in the order `execute` asks them, and `execute` refuses the + first, the name.""" + naming = NamingPolicy.load(REPO / "contracts" / "repository-naming.yaml") + plan = _Plan(org=an_org(OWNER_MAX + 1), project_id=an_id(ID_MAX + 1)) + refused = list(adopter._what_cannot_be_held( + plan, names_of(a_name(NAME_MAX - 4)), plan.data["org"], False, + naming)) + assert [exc.code for exc in refused] == [ + "adopt-name-too-long", "adopt-org-too-long", "adopt-topic-too-long"] + plan = _Plan(org="my.org") + assert [exc.code for exc in adopter._what_cannot_be_held( + plan, names_of(NORTHWIND), "my.org", False, naming)] == [ + "adopt-bad-org"] + assert list(adopter._what_cannot_be_held( + plan, names_of(NORTHWIND), "my.org", True, naming)) == [] + + +def test_a_bad_id_is_left_to_its_own_refusal(adopter): + """An id that is no project id is `plan-bad-id`, refused where it always + was: the list asks no topic of it, and is no second place to say it.""" + class BadId(_Plan): + @property + def project_id(self) -> str: + raise Refusal("plan-bad-id", "no", "Remediation: no.") + + naming = NamingPolicy.load(REPO / "contracts" / "repository-naming.yaml") + assert list(adopter._what_cannot_be_held( + BadId(), names_of(NORTHWIND), ORG, False, naming)) == [] + + +class _CheckThenExecute: + """`subprocess` as `github_run` sees it, but before its `execute`, + `check` is run on the same plan, in the same environment, without + `--yes`: its result and the `gh` calls it made are kept, and the calls + are cleared before `execute` runs. With `only_check`, `execute` is not + run at all, and `github_run` returns `check`'s result.""" + + def __init__(self, only_check: bool = False): + self.only_check = only_check + self.checked = None + self.check_calls = None + + def __getattr__(self, name): + return getattr(subprocess, name) + + def run(self, argv, *args, **kwargs): + if "execute" not in argv: + return subprocess.run(argv, *args, **kwargs) + check = ["check" if arg == "execute" else arg for arg in argv + if arg != "--yes"] + self.checked = subprocess.run(check, *args, **kwargs) + calls = Path(kwargs["env"]["FAKE_GH_ROOT"]) / "calls" + self.check_calls = gh_calls(calls.parent) + if self.only_check: + return self.checked + if calls.exists(): + calls.write_text("", encoding="utf-8") + return subprocess.run(argv, *args, **kwargs) + + +def findings(stderr: str) -> list: + """The code of each `FINDING` line, in order.""" + return [line.split(":", 1)[0] for line in stderr.splitlines() + if line.startswith("FINDING ")] + + +#: `(plan flags, the organisation, the project, the code)`: one past each +#: bound, and each character, in GitHub mode. The source is named on GitHub +#: with 96 characters, a name GitHub gives a repository, whose legs it does +#: not. +CHECKED_OVER = { + "project": (("--project", a_name(NAME_MAX - 4), "--id", "atl"), ORG, + a_name(NAME_MAX - 4), "adopt-name-too-long"), + "org": (("--org", an_org(OWNER_MAX + 1)), an_org(OWNER_MAX + 1), + NORTHWIND, "adopt-org-too-long"), + "id": (("--id", an_id(ID_MAX + 1)), ORG, NORTHWIND, + "adopt-topic-too-long"), + "derived-id": (("--project", a_name(ID_MAX + 1)), ORG, a_name(ID_MAX + 1), + "adopt-topic-too-long"), + **{f"org-{case}": (("--org", owner), owner, NORTHWIND, "adopt-bad-org") + for case, (owner, _) in MISSPELLED.items()}, +} + + +@WINDOWS_SKIP +@needs_filter_repo +@pytest.mark.parametrize("case", CHECKED_OVER) +def test_check_finds_what_execute_refuses_under_its_code(tmp_path, + monkeypatch, case): + """On main `check` said `plan ok` over each of these and `execute` + refused it. Now `check` finds it, exit 1, under `execute`'s code. + After a name or an organisation it asks GitHub nothing: their fix + renames both legs, so the leg question would be asked of the wrong + names. After a topic it still asks the two leg views, whose names an id + does not change (#871's review, S1). `execute` refuses it before a `gh` + call or a leg.""" + flags, org, project, code = CHECKED_OVER[case] + runs = _CheckThenExecute() + monkeypatch.setattr(test_adopt_github_exits, "subprocess", runs) + result, github = github_run(tmp_path, "", plan_flags=flags, + before_plan=a_root_named(org, project)) + checked = runs.checked + assert checked.returncode == 1, checked.stderr + checked.stdout + assert findings(checked.stderr) == [f"FINDING {code}"], checked.stderr + assert "\nplan ok" not in checked.stdout + assert runs.check_calls == ([f"repo view {org}/{project}-{leg}" + for leg in ("spec", "code")] + if code == "adopt-topic-too-long" else []) + assert result.returncode == 2, result.stderr + result.stdout + assert refusals(result.stderr) == [f"REFUSED {code}"], result.stderr + assert gh_calls(github) == [] + for leg in ("spec", "code"): + assert not (github / org / f"{project}-{leg}.git").exists(), leg + # The detail and the remediation are `execute`'s own, word for word. + finding = next(line for line in checked.stderr.splitlines() + if line.startswith(f"FINDING {code}: ")) + detail = finding.split(": ", 1)[1] + assert f"REFUSED {code}: {detail}\n" in result.stderr, result.stderr + remediation = result.stderr.split(f"REFUSED {code}: {detail}\n", 1)[1] + assert remediation.splitlines()[0] in checked.stderr.splitlines() + + +#: `(plan flags, the organisation, the project)`: at each bound, and `_`. +CHECKED_AT = { + "project": (("--project", a_name(NAME_MAX - 5), "--id", "atl"), ORG, + a_name(NAME_MAX - 5)), + "org": (("--org", an_org(OWNER_MAX)), an_org(OWNER_MAX), NORTHWIND), + "id": (("--id", an_id(ID_MAX)), ORG, NORTHWIND), + "org-underscore": (("--org", "ok_org"), "ok_org", NORTHWIND), +} + + +@WINDOWS_SKIP +@pytest.mark.parametrize("case", CHECKED_AT) +def test_check_passes_what_github_takes_and_asks_the_legs(tmp_path, + monkeypatch, case): + """At each bound `check` finds nothing of GitHub's limits and goes on + to #798's question, the two leg views.""" + flags, org, project = CHECKED_AT[case] + runs = _CheckThenExecute(only_check=True) + monkeypatch.setattr(test_adopt_github_exits, "subprocess", runs) + result, _ = github_run(tmp_path, "", plan_flags=flags, + before_plan=a_root_named(org, project)) + assert result.returncode == 0, result.stderr + result.stdout + assert "\nplan ok" in result.stdout + assert runs.check_calls == [f"repo view {org}/{project}-{leg}" + for leg in ("spec", "code")] + + +def a_code_leg_on_github(source: Path, github: Path) -> None: + """`github_run`'s `before_plan`: `testorg/Northwind-code` is already a + repository on the stand-in for github.com, so `gh repo view` finds it.""" + (github / ORG / f"{NORTHWIND}-code.git").mkdir() + + +@WINDOWS_SKIP +def test_check_asks_the_legs_beside_a_topic_it_finds(tmp_path, monkeypatch): + """#871's review, S1: an id of 40 characters, where the code leg's name + is already taken on GitHub. The id names no repository, so mending it + leaves both legs' names as they are: `check` lists the topic AND the + leg in one round, where at #871's first head it listed the topic alone + and the leg only once the id was mended.""" + runs = _CheckThenExecute(only_check=True) + monkeypatch.setattr(test_adopt_github_exits, "subprocess", runs) + result, _ = github_run(tmp_path, "", + plan_flags=("--id", an_id(ID_MAX + 1)), + before_plan=a_code_leg_on_github) + assert result.returncode == 1, result.stderr + result.stdout + assert findings(result.stderr) == ["FINDING adopt-topic-too-long", + "FINDING leg-remote-exists"], \ + result.stderr + assert runs.check_calls == [f"repo view {ORG}/{NORTHWIND}-{leg}" + for leg in ("spec", "code")] + + +@WINDOWS_SKIP +def test_check_asks_none_of_them_of_names_that_do_not_classify(tmp_path): + """#871's review, N5: `execute` refuses names the naming policy does + not classify (`_check_names`) before it asks `_what_cannot_be_held`, so + `check` asks that list only of names that classify. A spec leg edited + to `northwind_spec` in a plan whose `org:` and id are each one past + GitHub's bound gets the naming finding alone, and GitHub is asked + nothing.""" + source = make_source_repo(tmp_path / "Thing") + plan = tmp_path / "plan.yaml" + written = write_plan(source, plan, org=an_org(OWNER_MAX + 1), + extra=("--id", an_id(ID_MAX + 1))) + assert written.returncode == 0, written.stderr + written.stdout + for path, leg in ANSWERS: + resolve(plan, path, leg) + text = plan.read_text(encoding="utf-8") + leg_line = f" spec: {NORTHWIND}-spec\n" + assert leg_line in text, text + plan.write_bytes(text.replace(leg_line, " spec: northwind_spec\n", + 1).encode("utf-8")) + env, log = fake_github(tmp_path) + result = run_script(ADOPT, "check", "--plan", str(plan), env=env) + assert result.returncode == 1, result.stderr + result.stdout + assert findings(result.stderr) == ["FINDING naming-unclassified"], \ + result.stderr + assert not log.exists(), log.read_text(encoding="utf-8") + + +# --- one organisation, two refusals: the length, then the characters --------- + +#: An `--org` one past GitHub's 39 that holds a character GitHub gives no +#: organisation too (#871's review, N2). `check` lists both refusals, the +#: length first; every tool that refuses refuses the length, and the +#: characters on the run after it is mended. +LONG_AND_MISSPELLED = an_org(OWNER_MAX) + "." + + +def test_the_list_holds_an_organisations_length_before_its_characters( + adopter): + naming = NamingPolicy.load(REPO / "contracts" / "repository-naming.yaml") + assert [exc.code for exc in adopter._what_cannot_be_held( + _Plan(org=LONG_AND_MISSPELLED), names_of(NORTHWIND), + LONG_AND_MISSPELLED, False, naming)] == [ + "adopt-org-too-long", "adopt-bad-org"] + + +@WINDOWS_SKIP +def test_check_lists_an_organisations_length_then_its_characters( + tmp_path, monkeypatch): + """Both, in that order, each value shown cut (#871's review, N3), and no + leg question: another `org:` renames both legs.""" + runs = _CheckThenExecute(only_check=True) + monkeypatch.setattr(test_adopt_github_exits, "subprocess", runs) + result, _ = github_run(tmp_path, "", + plan_flags=("--org", LONG_AND_MISSPELLED), + before_plan=a_root_named(LONG_AND_MISSPELLED, + NORTHWIND)) + assert result.returncode == 1, result.stderr + result.stdout + assert findings(result.stderr) == ["FINDING adopt-org-too-long", + "FINDING adopt-bad-org"], result.stderr + shown = "`org:` is oooooooooooo...ooooooooooo., 40 characters, and GitHub " + for line in result.stderr.splitlines(): + if line.startswith("FINDING "): + assert shown in line, line + assert runs.check_calls == [] + + +def test_execute_refuses_an_organisations_length_first(adopter): + naming = NamingPolicy.load(REPO / "contracts" / "repository-naming.yaml") + run_args, plan, names = (SimpleNamespace(local_remote_dir=None), + _Plan(org=LONG_AND_MISSPELLED), + names_of(NORTHWIND)) + with pytest.raises(Refusal) as caught: + adopter._repository_urls(run_args, plan, names, None, naming) + assert caught.value.code == "adopt-org-too-long" + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real"]) +def test_init_refuses_an_organisations_length_first( + family, only_the_branch_question, capsys, tmp_path, dry_run): + err = refused_init(family, capsys, tmp_path, "--org", LONG_AND_MISSPELLED, + dry_run=dry_run) + assert refusals(err) == [FAMILY_ORG_TOO_LONG], err + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real"]) +def test_the_scaffold_refuses_an_organisations_length_first( + scaffold, no_process, capsys, dry_run): + err = refused_plan(scaffold, capsys, "--project", "Atlas", "--org", + LONG_AND_MISSPELLED, dry_run=dry_run) + assert refusals(err) == [SCAFFOLD_ORG_TOO_LONG], err + + +@WINDOWS_SKIP +def test_setup_refuses_an_organisations_length_first(tmp_path): + """Setup's step (5) is the scaffold's plan, and takes its answer.""" + result = run_entry(*setup_flags(tmp_path, "--project", "Atlas", "--org", + LONG_AND_MISSPELLED), + extra_env=setup_env(tmp_path)) + assert result.returncode == 2, result.stderr + result.stdout + assert f"{SCAFFOLD_ORG_TOO_LONG}: --org is " in result.stderr, \ + result.stderr + assert "bad-org" not in result.stderr, result.stderr + assert not (tmp_path / "parent").exists() + + +# --- adopt-project.py check under --local-remote-dir: the disk's ------------ + +def checked_on_disk(tmp_path: Path, length: int, *flags: str): + """`check` of a planned and answered `N` * `length` project, with + `flags`.""" + source = make_source_repo(tmp_path / "Thing") + plan = tmp_path / "plan.yaml" + written = write_plan(source, plan, project="N" * length) + assert written.returncode == 0, written.stderr + written.stdout + for path, leg in ANSWERS: + resolve(plan, path, leg) + return run_script(ADOPT, "check", "--plan", str(plan), *flags) + + +@pytest.mark.parametrize("length", [246, 247]) +def test_check_asks_the_disks_limit_under_a_local_remote_dir(tmp_path, + length): + """`<246>-spec.git` is 255 bytes, the most a disk keeps: `check` says + `plan ok`. One more is `execute`'s `adopt-name-too-long`, the disk's + words, and now `check`'s finding too.""" + result = checked_on_disk(tmp_path, length, "--local-remote-dir", + str(tmp_path / "remotes"), "--work-dir", + str(tmp_path / "work")) + if length == 246: + assert result.returncode == 0, result.stderr + result.stdout + assert "too-long" not in result.stderr + return + assert result.returncode == 1, result.stderr + result.stdout + assert findings(result.stderr) == ["FINDING adopt-name-too-long"] + assert ("FINDING adopt-name-too-long: NNNNNNNNNNNN...NNNNNNN-spec.git " + "is 256 bytes long, and the bare repository's directory under " + "--local-remote-dir may be at most 255; NNNNNNNNNNNN...NNNNNNN" + "-code.git is 256 bytes long, and the bare repository's directory " + "under --local-remote-dir may be at most 255.") in \ + result.stderr.splitlines() + assert not (tmp_path / "remotes").exists() diff --git a/tests/test_reader_refusals_named.py b/tests/test_reader_refusals_named.py index 13ba8c47..b16e990d 100644 --- a/tests/test_reader_refusals_named.py +++ b/tests/test_reader_refusals_named.py @@ -227,13 +227,18 @@ def test_plan_names_org_again_with_the_readers_remedy(world, org): def test_check_names_a_hand_edited_org(world): - """The same value edited into a plan: `check` names its key.""" + """The same value edited into a plan: `check` names its key. Run + without `--local-remote-dir`, it also finds GitHub's rule for an + organisation's characters, which `execute` asks there (#863).""" plan = edited(world, "org: testorg", 'org: "@x"') said = check(plan) assert said.returncode == 1, said.stderr + said.stdout lines = said.stderr.splitlines() assert [line for line in lines if line.startswith("FINDING ")] == [ - f"FINDING plan-manifest-invalid: {reader_sentence('org', '@x')}"] + f"FINDING plan-manifest-invalid: {reader_sentence('org', '@x')}", + f"FINDING adopt-bad-org: {plan}: `org:` is '@x', and GitHub gives no " + "user or organisation a name holding `@`, so no repository can be " + "created under it"] assert (f"{shape_materialize.MANIFEST_UNREADABLE_REMEDIATION} `execute` " "writes the plan's values into the project's `project.yaml` as " "they stand, so correct each one named here in the plan, or " diff --git a/tests/test_scaffold_manifest_values.py b/tests/test_scaffold_manifest_values.py index 2d40bcba..630bced4 100644 --- a/tests/test_scaffold_manifest_values.py +++ b/tests/test_scaffold_manifest_values.py @@ -538,8 +538,11 @@ def test_a_blank_that_is_also_a_line_break_is_refused_too(tmp_path, blank): def test_an_organisation_the_validator_accepts_is_still_accepted(tmp_path): - """`_` and `.` are in the validator's `QUALIFIED_RE`: the scaffold does - not refuse more than the project does.""" + """`_` and `.` are in the validator's `QUALIFIED_RE`, and the validator + still reads them as it did: under `--local-remote-dir`, as `scaffold` + runs here, the scaffold does not refuse more than the project does. + Where GitHub is asked, GitHub's own rule refuses the `.` as + `scaffold-bad-org` (#863, `tests/test_github_limits_init_check_org.py`).""" result = scaffold(tmp_path, "--dry-run", "--org=my_org.x") assert result.returncode == 0, result.stderr + result.stdout