From 96441cef112afa6e80871014a9a7dd3126a6f083 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 11:04:48 +0000 Subject: [PATCH 01/13] WIP (usage checkpoint 2026-10-17; untested on these bytes): adopt-project.py `execute` asks about both leg repositories before it makes either, and `check` asks the same (#798) State at the checkpoint: - tests/test_adopt_execute_refusals.py and tests/test_adopt_late_failure.py hold the new tests, written first; 22 of them fail on bf1f099b as intended (red run before any product change). - scripts/shape_materialize.py: `folder_in_the_way` split out of `folder_cannot_be_made`, no change in what the scaffold or family.py say. - adopt-project.py is NOT changed yet: the question, check's findings and the mkdir guard are still to write, then tests/gh_guard.py's answer for `gh repo view --json name`, the shims other tests use, docs/cli.md. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- scripts/shape_materialize.py | 22 +- tests/test_adopt_execute_refusals.py | 416 ++++++++++++++++++++++++++- tests/test_adopt_late_failure.py | 66 +++++ 3 files changed, 498 insertions(+), 6 deletions(-) diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 8e032b39..569cac38 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -1349,9 +1349,10 @@ def _not_a_folder(path: Path) -> str | None: else "is not a folder") -def folder_cannot_be_made(path: Path) -> str | None: - """Why `path` cannot be made as a folder, with every folder above it, and - written in, or None when it can (#609). It makes nothing. +def folder_in_the_way(path: Path) -> tuple[Path, str] | None: + """`(where, why)` when `path` cannot be made as a folder, with every + folder above it, and written in: the one that stands in the way, and + why. None when it can (#609). It makes nothing. The question `Path.mkdir(parents=True, exist_ok=True)` and the first write into the folder ask, put without making anything: the nearest of @@ -1360,19 +1361,30 @@ def folder_cannot_be_made(path: Path) -> str | None: write in (the system's own answer, `os.access`, where it has one; it says yes for root, and for any folder on Windows, as `mkdir` would). A `path` nothing stands at is asked of the folder above it. + + `folder_cannot_be_made` says it as one sentence. `adopt-project.py` + asks it of `--local-remote-dir` (#798), and names `where` its own way. """ for here in (path, *path.parents): if not os.path.lexists(here): continue why = _not_a_folder(here) if why: - return f"{here} {why}" + return here, why if not os.access(here, os.W_OK | os.X_OK): - return f"{here} {CANNOT_WRITE_IN}" + return here, CANNOT_WRITE_IN return None return None +def folder_cannot_be_made(path: Path) -> str | None: + """Why `path` cannot be made as a folder, with every folder above it, and + written in, or None when it can (#609): `folder_in_the_way`'s answer, as + ` `. It makes nothing.""" + found = folder_in_the_way(path) + return f"{found[0]} {found[1]}" if found else None + + def _unwritable_remediation(choose: str) -> str: """What a refusal of a folder this user cannot write in says to do, for a folder to make and for a working tree alike: give this user write and diff --git a/tests/test_adopt_execute_refusals.py b/tests/test_adopt_execute_refusals.py index 7c767af6..870aef14 100644 --- a/tests/test_adopt_execute_refusals.py +++ b/tests/test_adopt_execute_refusals.py @@ -35,9 +35,13 @@ import importlib.util import io +import json +import os import re +import shlex import shutil import sys +import textwrap from argparse import Namespace from dataclasses import dataclass from pathlib import Path @@ -45,7 +49,7 @@ import pytest from conftest import (ADOPT, git, make_source_repo, resolve, run_script, - write_plan) + spelled, write_plan) PROJECT = "Northwind" UTF8 = "utf-8" @@ -1314,6 +1318,416 @@ def test_execute_raises_the_refusal_not_the_decode_error(adopter, world): no_leg_was_created(world.tmp) +# --- both leg repositories, asked about before either is made (#798) -------- +# +# `execute` made the spec leg's repository and asked about the code leg's only +# as it made that one. So a code leg that already existed, on disk or on +# GitHub, was refused once the spec leg had been made, and a +# `--local-remote-dir` that could not hold them ended in a traceback (a file +# at the path, a folder above it this user cannot write in) or in a raw +# `git init` failure (a folder this user cannot write in), each after `check` +# had said `plan ok`. Both are asked about now before either is made, every +# finding is refused at once, and `check` prints the same findings under the +# same flags. + +LEG_EXISTS = "leg-remote-exists" +FOLDER_REFUSED = "adopt-folder-cannot-be-made" +REMOTE_UNREADABLE = "adopt-remote-unreadable" +LEG_CODES = (LEG_EXISTS, FOLDER_REFUSED, REMOTE_UNREADABLE) +ORG = "testorg" +#: What `_create_leg_remotes` prints once it starts making them. +CREATING = "creating the leg repositories" +#: How `execute`'s refusal of them begins its remediation. +REMEDIATION_FIRST = f"Remediation: {NO_LEG_MADE}." +#: The two answers a folder can give that refuse it. +A_FILE = "is a file, not a folder" +CANNOT_WRITE_IN = "is a folder this user cannot write in" +#: gh's own words, with exit 4, for a command that needs a login, on a +#: machine with none. +NO_LOGIN = "To get started with GitHub CLI, please run: gh auth login" +#: What `check` prints for a repository gh could not answer about. +COULD_NOT_ASK = "NOTE `check` could not ask GitHub whether " + + +def bare(tmp_path: Path, role: str) -> Path: + """Where `execute --local-remote-dir /remotes` makes `role`'s + leg, as it names it: under the directory resolved.""" + return (tmp_path / REMOTES).resolve() / f"{PROJECT}-{role}.git" + + +def repository(role: str) -> str: + """`role`'s leg repository on GitHub, as `execute` names it.""" + return f"{ORG}/{PROJECT}-{role}" + + +def view(role: str) -> list[str]: + """The `gh` call that asks whether `role`'s leg repository exists.""" + return ["repo", "view", repository(role), "--json", "name"] + + +def leg_findings(text: str) -> list[str]: + """Each FINDING about the leg repositories in `text`, as `check` prints + it: the lines of its stderr, or of `execute`'s refusal, indented.""" + return [line.strip() for line in text.splitlines() + if line.strip().startswith(tuple(f"FINDING {code}:" + for code in LEG_CODES))] + + +def on_disk(tmp_path: Path, role: str) -> str: + return (f"FINDING {LEG_EXISTS}: {spelled(bare(tmp_path, role))}, the " + f"{role} leg's bare repository, already exists") + + +def on_github(role: str) -> str: + return (f"FINDING {LEG_EXISTS}: {repository(role)}, the {role} leg's " + "repository, already exists on GitHub: `gh repo view " + f"{repository(role)} --json name` finds it") + + +def told(role: str, code: int, said: str) -> str: + """How a `gh repo view` that failed otherwise than not-found is named.""" + return (f"`gh repo view {repository(role)} --json name` exited {code}: " + f"{said}") + + +def unread(role: str, code: int, said: str) -> str: + return (f"FINDING {REMOTE_UNREADABLE}: whether {repository(role)}, the " + f"{role} leg's repository, already exists on GitHub cannot be " + f"told: {told(role, code, said)}") + + +def cannot_hold(local_remote_dir: Path, here: Path, why: str) -> str: + return (f"FINDING {FOLDER_REFUSED}: --local-remote-dir " + f"{spelled(local_remote_dir.resolve())} cannot hold the two leg " + f"repositories: {spelled(here.resolve())} {why}") + + +@pytest.fixture +def unwritable(): + """A maker of folders this user cannot write in: `make(path)` makes + `path` and sets it `0o555`, and each is made writable again for pytest + to delete. Skipped where a folder's mode refuses this user nothing: + root, and Windows.""" + made: list[Path] = [] + + def make(path: Path) -> Path: + if os.name == "nt" or os.geteuid() == 0: + pytest.skip("a folder's mode refuses nothing to this user here") + path.mkdir(parents=True) + path.chmod(0o555) + made.append(path) + return path + yield make + for path in made: + path.chmod(0o755) + + +def a_file(tmp_path: Path, _unwritable) -> tuple[Path, Path, str]: + """`(--local-remote-dir, what stands in the way, why)`: a file at it.""" + path = tmp_path / REMOTES + path.write_text("not a folder\n", encoding=UTF8) + return path, path, A_FILE + + +def under_an_unwritable_parent(tmp_path: Path, unwritable_folder): + parent = unwritable_folder(tmp_path / "read-only") + return parent / REMOTES, parent, CANNOT_WRITE_IN + + +def an_unwritable_directory(tmp_path: Path, unwritable_folder): + path = unwritable_folder(tmp_path / REMOTES) + return path, path, CANNOT_WRITE_IN + + +#: L1-72's three `--local-remote-dir`s, each refused by name. +FOLDERS = {"a-file": a_file, + "under-an-unwritable-parent": under_an_unwritable_parent, + "an-unwritable-directory": an_unwritable_directory} +EACH_FOLDER = pytest.mark.parametrize("layout", sorted(FOLDERS)) +#: The legs that already exist, each refused by name. +EACH_EXISTING = pytest.mark.parametrize("existing", [(CODE,), (SPEC,), ROLES], + ids=["code", "spec", "both"]) + + +def made_bare(tmp_path: Path, existing: tuple) -> None: + for role in existing: + git("init", "-q", "--bare", str(bare(tmp_path, role)), cwd=tmp_path) + + +def execute_here(world: World, local_remote_dir: Path) -> list[str]: + """`main`'s argv for `execute --yes` of the world's plan into + `local_remote_dir`.""" + return ["execute", "--plan", str(world.plan), "--yes", + "--local-remote-dir", str(local_remote_dir), "--work-dir", + str(world.tmp / WORK)] + + +@EACH_EXISTING +def test_a_leg_on_disk_is_refused_before_either_is_made(adopter, world, + capsys, existing): + """L1-73, local: with `

-code.git` there, `execute` made + `

-spec.git` and was then refused at the code leg. Both are asked + about first, each one there is named, and none is made.""" + made_bare(world.tmp, existing) + refusal = refused_by(adopter, execute_args(world.tmp, world.plan)) + assert refusal.code == LEG_EXISTS + assert leg_findings(refusal.detail) == [on_disk(world.tmp, role) + for role in existing] + assert refusal.remediation.startswith(REMEDIATION_FIRST) + for role in ROLES: + assert bare(world.tmp, role).exists() == (role in existing), role + assert CREATING not in capsys.readouterr().out + + +@EACH_FOLDER +def test_a_local_remote_dir_that_cannot_hold_the_legs_is_refused_by_name( + adopter, world, capsys, unwritable, layout): + """L1-72: a file at the path ended in a `FileExistsError` traceback, a + path under a folder this user cannot write in in a `PermissionError` + one, and a folder this user cannot write in in `git init`'s raw + failure. Each is refused by name now, exit 2, before anything is + made.""" + local_remote_dir, here, why = FOLDERS[layout](world.tmp, unwritable) + assert adopter.main(execute_here(world, local_remote_dir)) == 2 + err = capsys.readouterr().err + assert f"REFUSED {FOLDER_REFUSED}: " in err + assert leg_findings(err) == [cannot_hold(local_remote_dir, here, why)] + assert not list(world.tmp.rglob(f"{PROJECT}-*.git")) + + +def local_case(world: World, unwritable_folder, case: str) -> Path: + """The `--local-remote-dir` of `case`: one of `FOLDERS`, or legs on disk + under `/remotes`.""" + if case in FOLDERS: + return FOLDERS[case](world.tmp, unwritable_folder)[0] + made_bare(world.tmp, (CODE,) if case == "code-exists" else ROLES) + return world.tmp / REMOTES + + +@pytest.mark.parametrize("case", ["code-exists", "both-exist", *sorted(FOLDERS)]) +def test_check_given_the_same_local_remote_dir_prints_what_execute_refuses( + adopter, world, unwritable, case): + """#417's one verdict: `check --local-remote-dir` said `plan ok` in + every case `execute` then refused. It prints each finding now, word for + word as `execute`'s refusal lists it, and fails.""" + local_remote_dir = local_case(world, unwritable, case) + checked = run_script(ADOPT, "check", "--plan", str(world.plan), + "--local-remote-dir", str(local_remote_dir)) + assert checked.returncode == 1, checked.stderr + checked.stdout + args = execute_args(world.tmp, world.plan) + args.local_remote_dir = local_remote_dir + refusal = refused_by(adopter, args) + printed = leg_findings(checked.stderr) + assert printed, checked.stderr + assert printed == leg_findings(refusal.detail) + + +#: A `gh` that logs each call, as JSON, to $GH_STUB_LOG and answers as GitHub +#: would for the repositories in $GH_STUB_EXISTING: `repo view` of one there +#: finds it, and of any other says gh's not-found words, exit 1; `repo create` +#: makes any other, and of one there, or of one in $GH_STUB_HIDDEN (a +#: repository the token cannot see), says the name is taken. With +#: $GH_STUB_VIEW_FAILS (` `) every `repo view` fails so. +GH_STUB = textwrap.dedent("""\ + import json, os, sys + argv = sys.argv[1:] + with open(os.environ["GH_STUB_LOG"], "a", encoding="utf-8") as log: + log.write(json.dumps(argv) + "\\n") + existing = os.environ.get("GH_STUB_EXISTING", "").split() + failing = os.environ.get("GH_STUB_VIEW_FAILS") + if argv[:2] == ["repo", "view"] and failing: + code, said = failing.split(" ", 1) + sys.stderr.write(said + "\\n") + sys.exit(int(code)) + if argv[:2] == ["repo", "view"] and argv[2] in existing: + print(json.dumps({"name": argv[2].split("/")[-1]})) + sys.exit(0) + if argv[:2] == ["repo", "view"]: + sys.stderr.write("GraphQL: Could not resolve to a Repository with " + f"the name '{argv[2]}'. (repository)\\n") + sys.exit(1) + hidden = os.environ.get("GH_STUB_HIDDEN", "").split() + if argv[:2] == ["repo", "create"] and argv[2] in existing + hidden: + sys.stderr.write("GraphQL: Name already exists on this account " + "(createRepository)\\n") + sys.exit(1) + if argv[:2] == ["repo", "create"]: + print(f"https://github.com/{argv[2]}") + sys.exit(0) + sys.stderr.write("gh stub: a call no test here expects\\n") + sys.exit(97) + """) + + +@dataclass(frozen=True) +class GitHub: + """What `on_github` set up: the log its `gh` keeps.""" + + log: Path + + def calls(self, words: int = 0) -> list[list[str]]: + """Each call the stub was run with, in order: its whole argv, or + its first `words`.""" + if not self.log.exists(): + return [] + return [json.loads(line)[:words or None] + for line in self.log.read_text(encoding=UTF8).splitlines()] + + +@pytest.fixture +def github(world, monkeypatch) -> GitHub: + """`execute` and `check` against GitHub, with no network: `GH_STUB` + first on PATH, ahead of the session's guard, with a token no `gh` takes + and an empty config directory, and every `https://github.com/` url git + reads rewritten to a directory that holds the assembly, a bare clone of + the source. A `#!` launcher, so not on Windows.""" + if os.name == "nt": + pytest.skip("the stub gh is a #! script") + tmp = world.tmp + stand_in = tmp / "github" + git("clone", "-q", "--bare", str(world.source), + str(stand_in / ORG / f"{PROJECT}.git"), cwd=tmp) + config = tmp / "gitconfig" + config.write_text(f'[url "{stand_in.as_posix()}/"]\n' + "\tinsteadOf = https://github.com/\n", encoding=UTF8) + bin_dir = tmp / "bin" + bin_dir.mkdir() + script = bin_dir / "gh_stub.py" + script.write_text(GH_STUB, encoding=UTF8) + launcher = bin_dir / "gh" + launcher.write_bytes(f"#!/bin/sh\nexec {shlex.quote(sys.executable)} " + f"{shlex.quote(str(script))} \"$@\"\n".encode(UTF8)) + launcher.chmod(0o755) + (tmp / "gh-config").mkdir() + for name, value in { + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "GH_STUB_LOG": str(tmp / "gh.log"), + "GH_TOKEN": "bogus-token-for-tests", + "GH_CONFIG_DIR": str(tmp / "gh-config"), + "GIT_CONFIG_GLOBAL": str(config)}.items(): + monkeypatch.setenv(name, value) + return GitHub(tmp / "gh.log") + + +def against_github(world: World) -> Namespace: + """`execute --yes` of the world's plan with no `--local-remote-dir`.""" + args = execute_args(world.tmp, world.plan) + args.local_remote_dir = None + return args + + +@pytest.mark.parametrize("existing", [(CODE,), ROLES], ids=["code", "both"]) +def test_a_leg_name_github_has_is_refused_before_any_repo_create( + adopter, world, github, monkeypatch, existing): + """L1-73, GitHub: the only `gh` calls were `repo create

-spec`, then + `repo create

-code`, which found the name taken, and a private + `

-spec` stayed behind. Both names are asked with `gh repo view` + first, and a name that exists is refused with no `repo create`.""" + monkeypatch.setenv("GH_STUB_EXISTING", + " ".join(repository(role) for role in existing)) + refusal = refused_by(adopter, against_github(world)) + assert refusal.code == LEG_EXISTS + assert leg_findings(refusal.detail) == [on_github(role) + for role in existing] + assert refusal.remediation.startswith(REMEDIATION_FIRST) + assert github.calls() == [view(SPEC), view(CODE)] + + +#: gh's failures that are not its answer that a repository does not exist, +#: `(exit, first line)`: neither says whether it does. +OTHER_FAILURES = { + "no-login": (4, NO_LOGIN), + "bad-credentials": (1, "HTTP 401: Bad credentials " + "(https://api.github.com/graphql)"), +} + + +@pytest.mark.parametrize("failure", sorted(OTHER_FAILURES)) +def test_a_view_that_fails_otherwise_is_refused_by_name( + adopter, world, github, monkeypatch, failure): + """Only gh's not-found answer means absent, as #622 has it for the + scaffold: any other failure of `gh repo view` is refused by name, the + command, its exit and gh's first line, before any `repo create`.""" + code, said = OTHER_FAILURES[failure] + monkeypatch.setenv("GH_STUB_VIEW_FAILS", f"{code} {said}") + refusal = refused_by(adopter, against_github(world)) + assert refusal.code == REMOTE_UNREADABLE + assert leg_findings(refusal.detail) == [unread(role, code, said) + for role in ROLES] + assert github.calls() == [view(SPEC), view(CODE)] + + +def test_a_gh_that_cannot_be_run_is_refused_by_name(adopter, world, github, + monkeypatch): + """A `gh` that cannot be started at all says nothing of either name + either: refused by name, not a `FileNotFoundError` traceback at the first + `repo create`.""" + real_run = adopter.run + + def no_gh(args, *rest, **named): + if args[0] == "gh": + raise FileNotFoundError(2, "No such file or directory", "gh") + return real_run(args, *rest, **named) + + monkeypatch.setattr(adopter, "run", no_gh) + refusal = refused_by(adopter, against_github(world)) + assert refusal.code == REMOTE_UNREADABLE + assert ("cannot be told: `gh` could not be run: [Errno 2] No such file or " + "directory: 'gh'") in refusal.detail + assert github.calls() == [] + + +def test_check_finds_a_leg_name_github_has_as_execute_refuses_it( + adopter, world, github, monkeypatch): + """`check`, given no `--local-remote-dir`, asks GitHub the same two + questions, and a name that exists is a FINDING, word for word as + `execute` refuses it.""" + monkeypatch.setenv("GH_STUB_EXISTING", repository(CODE)) + checked = run_check(world.plan) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert leg_findings(checked.stderr) == [on_github(CODE)] + assert github.calls() == [view(SPEC), view(CODE)] + refusal = refused_by(adopter, against_github(world)) + assert leg_findings(refusal.detail) == leg_findings(checked.stderr) + + +@pytest.mark.parametrize("failure", sorted(OTHER_FAILURES)) +def test_check_notes_a_view_that_fails_otherwise_and_still_passes( + world, github, monkeypatch, failure): + """Where gh cannot answer -- no login, a bad token, no network -- + `check` names what it could not ask in a NOTE and still passes, so it + stays usable offline; `execute` refuses the same (above).""" + code, said = OTHER_FAILURES[failure] + monkeypatch.setenv("GH_STUB_VIEW_FAILS", f"{code} {said}") + checked = run_check(world.plan) + assert checked.returncode == 0, checked.stderr + checked.stdout + assert leg_findings(checked.stderr) == [] + notes = [line for line in checked.stdout.splitlines() + if line.startswith(COULD_NOT_ASK)] + assert [role for role in ROLES + if any(told(role, code, said) in note for note in notes)] == \ + list(ROLES), notes + assert len(notes) == len(ROLES), notes + assert github.calls() == [view(SPEC), view(CODE)] + + +def test_a_name_github_hides_still_gets_the_notice_once_the_spec_leg_exists( + adopter, world, github, monkeypatch, capsys): + """THE NOTICE OF #188 N4 STAYS for what can still fail after the first + `repo create`: GitHub answers a repository the token cannot see as not + found, so both questions pass, and `repo create` then finds the code + leg's name taken once the spec leg's repository exists.""" + monkeypatch.setenv("GH_STUB_HIDDEN", repository(CODE)) + with pytest.raises(adopter.CommandFailed): + adopter.cmd_execute(against_github(world)) + assert github.calls(3) == [view(SPEC)[:3], view(CODE)[:3], + ["repo", "create", repository(SPEC)], + ["repo", "create", repository(CODE)]] + err = capsys.readouterr().err + assert (f"{LATE_NOTICE}: `https://github.com/{repository(SPEC)}.git` " + "exists, and nothing is pushed to it.") in err + + # --- end to end ------------------------------------------------------------ @needs_filter_repo diff --git a/tests/test_adopt_late_failure.py b/tests/test_adopt_late_failure.py index d8cce966..19acf907 100644 --- a/tests/test_adopt_late_failure.py +++ b/tests/test_adopt_late_failure.py @@ -1301,6 +1301,72 @@ def test_a_branch_the_split_push_fast_forwards_is_not_refused( BRANCH, True) is None +def after_the_question(adopter, monkeypatch, then) -> None: + """`_leg_remote_answers`, which asks about both leg repositories before + `execute` makes either (#798), answers as it does, and then `then()` + runs: what another run or a person does between that question and the + first make.""" + asked = adopter._leg_remote_answers + + def answered_then(*args): + answers = asked(*args) + then() + return answers + + monkeypatch.setattr(adopter, "_leg_remote_answers", answered_then) + + +def test_a_code_leg_made_after_the_question_still_gets_the_notice( + adopter, monkeypatch, capsys, tmp_path): + """#188 N4's notice STAYS for a failure that still comes after the + first make (#798). Both leg repositories are asked about first now, so + a `

-code.git` already there is refused before either is made; one + made after that question meets `_create_leg_remote`'s own + `leg-remote-exists` once the spec leg's exists, and the notice names + that one.""" + no_filter_repo_probe(adopter, monkeypatch) + plan = planned(tmp_path, make_source_repo(tmp_path / SOURCE, + tree=SMALL_TREE, edits=())) + code_leg = tmp_path / REMOTES / f"{ASSEMBLY}-{CODE}.git" + after_the_question(adopter, monkeypatch, lambda: git( + "init", "-q", "--bare", str(code_leg), cwd=tmp_path)) + + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(tmp_path, plan)) + + assert caught.value.code == "leg-remote-exists" + err = capsys.readouterr().err + assert f"{REFUSED} creating the {CODE} leg repository. " in err + spec_leg = (tmp_path / REMOTES).resolve() / f"{ASSEMBLY}-{SPEC}.git" + assert the_notice(err).startswith( + f"{ROLLED_BACK}: {spelled(spec_leg)} exists, and nothing is pushed " + "to it."), err + + +def test_a_local_remote_dir_that_cannot_be_made_after_the_question_is_refused( + adopter, monkeypatch, capsys, tmp_path): + """The `mkdir` of `--local-remote-dir` that follows the question raised + `FileExistsError` or `PermissionError` out of `main`, a traceback, for + what the question could not foresee (a file put there since). It is + refused by name, exit 2, before any leg exists, so with no notice.""" + no_filter_repo_probe(adopter, monkeypatch) + plan = planned(tmp_path, make_source_repo(tmp_path / SOURCE, + tree=SMALL_TREE, edits=())) + remotes = tmp_path / REMOTES + after_the_question(adopter, monkeypatch, lambda: remotes.write_text( + "put here since\n", encoding=UTF8)) + + assert adopter.main(["execute", PLAN_FLAG, str(plan), "--yes", + "--local-remote-dir", str(remotes), "--work-dir", + str(tmp_path / WORK)]) == 2 + + err = capsys.readouterr().err + assert (f"{REFUSED} adopt-folder-cannot-be-made: --local-remote-dir " + f"{spelled(remotes.resolve())} cannot be made: ") in err, err + assert ROLLED_BACK not in err + assert remotes.is_file() + + def test_a_refusal_from_the_substitution_table_leaves_no_leg_repository( adopter, monkeypatch, tmp_path): """The table can refuse -- an `elected_on` that is not a date, a From 8466900cbfd69d2cb978ca16298ebb2d97afac4a Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 14:14:34 +0000 Subject: [PATCH 02/13] adopt-project.py: `execute` asks about both leg repositories before it makes either, on the disk or on GitHub, and `check` asks the same (#798) `_refuse_what_stands_in_the_legs_way` runs right before `_create_leg_remotes` and refuses everything at once: a leg's bare repository already there and a `--local-remote-dir` that cannot be made and written in (`folder_in_the_way`, #609), or, against GitHub, a leg name `gh repo view` finds, and a view that fails otherwise than gh's not-found answer. `check` prints the same findings last, and a NOTE where gh cannot answer. The mkdir that follows refuses by name. tests/gh_guard.py answers `gh repo view / --json name` as a gh with no login does; test_adopt_pins.py's `check` runs take the test path's `--local-remote-dir`, and test_adopt_github_exits.py's shim answers the view from its fake GitHub. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 336 ++++++++++++++++++++++++++++--- docs/cli.md | 15 +- tests/gh_guard.py | 57 +++++- tests/test_adopt_github_exits.py | 32 ++- tests/test_adopt_late_failure.py | 2 +- tests/test_adopt_pins.py | 40 ++-- tests/test_gh_guard.py | 35 +++- 7 files changed, 454 insertions(+), 63 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 6e56e5df..6887243e 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -89,9 +89,16 @@ that clone is still on this machine, with no network: a partial clone (`git clone --filter`), whose `git` would fetch what it lacks, is not asked, and a NOTE names each pin it was not - rechecked for (#449). It prints what will happen and changes + rechecked for (#449). Last, it asks what `execute` asks before + it makes either leg (#798): given `--local-remote-dir`, whether + each leg's bare repository is already there and whether that + directory can be made and written in, and without it, with `gh + repo view`, whether GitHub already has either leg's name -- a + NOTE and no finding where gh cannot answer, so that `check` + stays usable offline. It prints what will happen and changes nothing. - execute creates the two legs, extracts them with `git filter-repo`, makes + 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 `xf-project-` topic on all three and then opens the split's pull request against the tracking branch (both skipped for local @@ -117,6 +124,10 @@ whether it answers, and the place outside the assembly that a symlink kept at a path the shape writes leads to asked again of the disk, a climb from its own work directory. +And last, before it makes either leg, it refuses at once everything that +stands in their way (#798), as `check` finds it: a leg repository already +there, on the disk or on GitHub, a `--local-remote-dir` that cannot hold +them, and a `gh repo view` that fails otherwise than gh's not-found answer. THE NEW PROJECT MUST BE ABLE TO HOLD WHAT `execute` WRITES INTO IT (#261). `execute` puts the plan's values into the root's `project.yaml`, and the @@ -266,6 +277,13 @@ from shape_materialize import ( # noqa: E402 free_plan_secrets_note, visibility_in, ) +# What `execute` asks of both leg repositories before it makes either, and +# `check` with it (#798): the folder `--local-remote-dir` names, asked as the +# scaffold and `family.py` ask theirs, and gh's not-found answer, the one +# failure of `gh repo view` that means a repository is absent (#609). +from shape_materialize import ( # noqa: E402 + CANNOT_WRITE_IN, GH_NOT_FOUND_WORDS, folder_in_the_way, +) #: The naming policy this tool classifies leg names against. One constant, #: because three spellings of the same path is how the second one goes stale. @@ -9573,6 +9591,17 @@ def cmd_check(args) -> int: + (f"/{found.role}" if found.role else "")) except Refusal as exc: findings.append(f"FINDING {exc.code}: {exc.detail}") + else: + # 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`. + # A question gh cannot answer is a NOTE here, not a finding. + legs, fixes, unasked = _leg_remote_findings(plan, names, + local_remote_dir) + findings.extend(legs) + remediation.extend(fixes) + notes = [*notes, *unasked] _print_what_will_happen(plan, source, names, naming.topic_for(plan.project_id), @@ -9819,9 +9848,9 @@ def _nothing_rolled_back(urls: dict, pushed: list, work_root: Path, source: Source | None = None) -> str: """The one notice for a failure once a leg repository exists. - `_create_leg_remotes` refuses a re-run while they exist: - `leg-remote-exists` under `--local-remote-dir`, and a `gh repo create` - that finds the name taken against GitHub. Every failure after it + `execute` refuses a re-run while they exist, before it makes either + (#798): `leg-remote-exists`, under `--local-remote-dir` and against + GitHub alike. Every failure after it therefore names the leg repositories this run `made` (both, unless the second could not be created, #188 N4), says which legs are already pushed (`pushed`, the roles in the order they were pushed), and gives the way @@ -9873,9 +9902,9 @@ def _nothing_rolled_back(urls: dict, pushed: list, work_root: Path, notice = (f"NOTHING has been rolled back: {named} {exist}, and {state}. " f"{rerun} the adoption with {them} deleted or with {fresh} (a " "new --local-remote-dir, or new leg repositories): a re-run " - f"that meets {them} is refused, as `leg-remote-exists` under " - "--local-remote-dir, and against GitHub by `gh repo create`, " - "which finds the name already taken.") + f"that meets {them} is refused as `leg-remote-exists` before " + "it makes either leg, under --local-remote-dir and against " + "GitHub alike.") built = (_cloned_here(source, work_root) + [name for name, _ in _built_in(work_root, names)]) if built: @@ -10154,25 +10183,269 @@ def _refuse_a_split_branch_left_behind(source: Source, urls: dict, + _branch_deletion(source, urls, branch)) +# --------------------------------------------------------------------------- +# Both leg repositories, asked about before either is made (#798) +# --------------------------------------------------------------------------- +# +# `_create_leg_remotes` made the spec leg's repository and asked about the +# code leg's only as it made that one: `leg-remote-exists` under +# `--local-remote-dir`, and `gh repo create` finding the name taken against +# GitHub. So a code leg that already existed was refused once the spec leg +# had been made, which #188 N4's notice named and nothing prevented; on +# GitHub that left a stray private `

-spec`, whose removal needs the +# `delete_repo` scope. And a `--local-remote-dir` that could not hold them +# ended in a traceback (a file at the path, `FileExistsError`; a folder above +# it this user cannot write in, `PermissionError`) or in `git init`'s raw +# failure (a folder this user cannot write in), each after `check` had said +# `plan ok`. +# +# BOTH ARE ASKED ABOUT FIRST, and everything that stands in their way is +# refused at once (`_refuse_what_stands_in_the_legs_way`): +# +# - under `--local-remote-dir`, whether each bare repository's path is taken +# (`os.path.lexists`, so a dangling link is taken too), the spec leg's and +# then the code leg's, and then whether the directory can be made and +# written in, asked as the scaffold and `family.py` ask theirs +# (`folder_in_the_way`, #609), which makes nothing; +# - against GitHub, `gh repo view` of the spec leg's name and then the code +# leg's. ONLY GH'S NOT-FOUND ANSWER MEANS ABSENT, as #609 has it for the +# scaffold: a name that exists is `leg-remote-exists` before any `gh repo +# create`, and a view that fails otherwise is `adopt-remote-unreadable`, +# naming the command, its exit and gh's first line. +# +# WHERE: right before `_create_leg_remotes`, after the yes, where +# `leg-remote-exists` was always refused. Every refusal before it keeps its +# order, and its precedence over this one: `adopt-branch-exists` and +# `plan-split-branch-taken` already defer to a leg still on disk. And GitHub +# is asked as close to `gh repo create` as it can be. +# +# `check` ASKS THE SAME, under the same flags and in the same order (#417's +# one verdict): given `--local-remote-dir`, of the disk, and without it, of +# GitHub; its findings come last, as `execute` asks them last. One +# difference: where gh cannot answer -- no login, a bad token, a rate limit, +# no network -- `check` says what it could not ask in a NOTE and still +# passes, so that it stays usable offline, where `execute` refuses. +# +# WHAT CAN STILL FAIL AFTER THE FIRST MAKE keeps #188 N4's notice +# (`_create_leg_remotes`): GitHub answers a repository the token cannot see +# as not found, and `gh repo create` then finds the name taken; a rate limit +# or an outage between the two creates; and under `--local-remote-dir` a +# path made since the question, or a disk that fills, at the code leg's +# `git init`. + +#: `check`'s finding, and `execute`'s refusal, of a leg repository that +#: already exists (#174, #798). +LEG_REMOTE_EXISTS = "leg-remote-exists" +#: ... of a `--local-remote-dir` that cannot hold them, under the name the +#: scaffold and `family.py` give theirs (#609). +LEG_FOLDER_REFUSED = "adopt-folder-cannot-be-made" +#: What each says to do: `check` after `Remediation:`, and `execute` after +#: `NO_LEG_YET`. +ON_DISK_FIX = ( + "Choose an empty --local-remote-dir, and a --work-dir the run that made " + "it did not clone into (a fresh one, or none): `git clone` refuses a " + "directory it already cloned into. There is no --force: re-running over " + "a live leg is not an adoption.") +ON_GITHUB_FIX = ( + "A leg repository is created new, never over one that exists, and there " + "is no --force: re-running over a live leg is not an adoption. If an " + "earlier `execute` made it and its adoption is not one to keep, delete " + "it (`gh repo delete `, which needs the `delete_repo` " + "scope); another repository of that name is renamed on GitHub first. " + "Then run `check` and `execute` again.") +UNREAD_FIX = ( + "Only gh's own answer that a repository does not exist (exit 1, " + f"`{GH_NOT_FOUND_WORDS}`) lets `execute` create one; any other failure " + "says nothing about whether it is there. Check `gh auth status` (`gh " + "auth login` where it names a token that is missing, bad or expired), " + "or let a rate limit or an outage pass, then run `execute` again.") +FOLDER_FIX = ( + "Move what stands in the way aside, or pass a --local-remote-dir where " + "a folder can be made. There is no --force.") +UNWRITABLE_FOLDER_FIX = ( + "Give this user write and search permission on that folder, or pass " + "another --local-remote-dir.") + + +def _leg_remote_answers(repositories: dict, names: dict, + local_remote_dir: Path | None) -> list[tuple]: + """`(code, detail, fix, note)` for each thing that stands in the way of + making the two leg repositories, in the order they are asked (#798): + nothing when both can be made. It makes nothing. + + Under `local_remote_dir` the disk is asked (`_in_the_way_on_disk`), and + otherwise GitHub, of `repositories` (`_in_the_way_on_github`). `note` is + what `check` prints instead of a finding, for a question gh could not + answer, and "" for every finding.""" + if local_remote_dir is not None: + return _in_the_way_on_disk(names, local_remote_dir) + asked = [_in_the_way_on_github(role, repositories[role]) + for role in EXTRACTED_LEGS] + return [answer for answer in asked if answer] + + +def _the_remote_dir(local_remote_dir: Path) -> Path: + """`--local-remote-dir` as `_remote_urls` makes each leg's url of it, + resolved; made absolute as given where it cannot be resolved (a link + loop raises), for `folder_in_the_way` to name what is wrong with it.""" + try: + return local_remote_dir.resolve() + except (OSError, RuntimeError): + return local_remote_dir.absolute() + + +def _in_the_way_on_disk(names: dict, local_remote_dir: Path) -> list[tuple]: + """The disk's answers under `--local-remote-dir`: each leg's bare + repository path that something already takes, the spec leg's and then + the code leg's, and then the directory, where it cannot be made and + written in. Every path `_spelled`, as the notice spells it (#212).""" + remote_dir = _the_remote_dir(local_remote_dir) + found = [] + for role in EXTRACTED_LEGS: + bare = remote_dir / f"{names[role]}.git" + if os.path.lexists(bare): + found.append((LEG_REMOTE_EXISTS, + f"{_spelled(str(bare))}, the {role} leg's bare " + "repository, already exists", ON_DISK_FIX, "")) + in_the_way = folder_in_the_way(remote_dir) + if in_the_way: + here, why = in_the_way + found.append(( + LEG_FOLDER_REFUSED, + f"--local-remote-dir {_spelled(str(remote_dir))} cannot hold the " + f"two leg repositories: {_spelled(str(here))} {why}", + UNWRITABLE_FOLDER_FIX if why == CANNOT_WRITE_IN else FOLDER_FIX, + "")) + return found + + +def _first_said(output: str) -> str: + """`: `, each character a terminal would act on + spelled as `_spelled` spells it; "" when it said nothing.""" + lines = [line.strip() for line in output.splitlines() if line.strip()] + if not lines: + return "" + return ": " + "".join(char if char.isprintable() + else _spelled_character(char) for char in lines[0]) + + +def _in_the_way_on_github(role: str, repository: str) -> tuple | None: + """GitHub's answer for one leg's `repository`, by `gh repo view`: None + when gh says it does not exist, `leg-remote-exists` when it does, and + `adopt-remote-unreadable` when gh failed otherwise or could not be run, + which says nothing either way (#609).""" + command = ["gh", "repo", "view", repository, "--json", "name"] + shown = " ".join(command) + try: + run(command) + except CommandFailed as exc: + if exc.code == 1 and GH_NOT_FOUND_WORDS in exc.output: + return None + how = f"`{shown}` exited {exc.code}{_first_said(exc.output)}" + except OSError as exc: + how = f"`gh` could not be run: {exc}" + else: + return (LEG_REMOTE_EXISTS, + f"{repository}, the {role} leg's repository, already exists " + f"on GitHub: `{shown}` finds it", ON_GITHUB_FIX, "") + named = f"{repository}, the {role} leg's repository," + return (REMOTE_UNREADABLE, + f"whether {named} already exists on GitHub cannot be told: {how}", + UNREAD_FIX, + f"NOTE `check` could not ask GitHub whether {named} already " + f"exists: {how}. It is no finding here, so that `check` stays " + "usable offline; `execute` asks again before it makes either " + f"leg, and refuses as `{REMOTE_UNREADABLE}` while gh cannot " + "answer.") + + +def _refuse_what_stands_in_the_legs_way(repositories: dict, names: dict, + local_remote_dir: Path | None + ) -> None: + """Refuse every answer `_leg_remote_answers` gives at once, BEFORE + either leg repository is made (#798): the code is the first one's own, + the detail lists each as `check` prints it, and the remediation says + what each asks for, once.""" + answers = _leg_remote_answers(repositories, names, local_remote_dir) + if not answers: + return + raise Refusal( + answers[0][0], + "\n".join([f"{len(answers)} finding(s) about the two leg " + "repositories, asked before either is made:", + *(f" FINDING {code}: {detail}" + for code, detail, _fix, _note in answers)]), + f"Remediation: {NO_LEG_YET}" + " ".join( + dict.fromkeys(fix for _code, _detail, fix, _note in answers))) + + +def _leg_remote_findings(plan: Plan, names: dict, + local_remote_dir: Path | None + ) -> tuple[list[str], list[str], list[str]]: + """`check`'s `(findings, remediation, notes)` about the two leg + repositories: `execute`'s answers (#798), each finding as its refusal + lists it, and a NOTE for each question gh could not answer. GitHub is + asked only of an `org:` `execute` would put on a `gh` command line; + another is `execute`'s to refuse first, as `unsafe-value`.""" + repositories = {} + if local_remote_dir is None: + try: + org = checked_value("org", _plan_org(plan)) + except Refusal: + return [], [], [] + repositories = {role: f"{org}/{names[role]}" + for role in EXTRACTED_LEGS} + findings, fixes, notes = [], [], [] + for code, detail, fix, note in _leg_remote_answers( + repositories, names, local_remote_dir): + if note: + notes.append(note) + else: + findings.append(f"FINDING {code}: {detail}") + fixes.append(f"Remediation: {fix}") + return findings, list(dict.fromkeys(fixes)), notes + + +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 + since, a permission `os.access` did not foresee -- is refused by name, + before any leg exists, and is no `OSError` traceback (#798).""" + try: + remote_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise Refusal( + LEG_FOLDER_REFUSED, + f"--local-remote-dir {_spelled(str(remote_dir))} cannot be made: " + f"{exc.strerror or exc}", + f"Remediation: {NO_LEG_YET}" + + (UNWRITABLE_FOLDER_FIX if isinstance(exc, PermissionError) + else FOLDER_FIX)) from exc + + def _create_leg_remotes(plan: Plan, names: dict, repositories: dict, urls: dict, tracking: str, local: bool, work_root: Path, *, source: Source | None = None) -> None: """(a) The two NEW repositories. The assembly root is never created here. - The spec leg's repository is created first, so a failure creating the - code leg's leaves one repository behind, and the re-run meets it: - against GitHub as `gh repo create` finding its name taken. That failure - therefore gets the notice too, naming the one that exists (#188 N4), and - is re-raised `reported`: `main` exits 2 on any of `LATE_FAILURES` without - printing it twice, and anything else (Ctrl-C) keeps its traceback. A - failure on the first leg leaves nothing, and `main` reports it as before. - `source` is handed on for the notice: nothing is built in the work - directory yet, but an `org/repo` source is already cloned there. + Both were asked about first (`_refuse_what_stands_in_the_legs_way`, + #798), so a name or a path already taken, and a `--local-remote-dir` + that cannot hold them, are refused before this runs. The spec leg's + repository is created first, so a failure creating the code leg's + that the question could not foresee -- a name GitHub hides from this + token, a path made since -- leaves one repository behind, and the re-run + meets it. That failure therefore gets the notice too, naming the one + that exists (#188 N4), and is re-raised `reported`: `main` exits 2 on + any of `LATE_FAILURES` without printing it twice, and anything else + (Ctrl-C) keeps its traceback. A failure on the first leg leaves nothing, + and `main` reports it as before. `source` is handed on for the notice: + nothing is built in the work directory yet, but an `org/repo` source is + already cloned there. """ print("\ncreating the leg repositories") if local: - Path(urls["spec"]).parent.mkdir(parents=True, exist_ok=True) + _make_the_remote_dir(Path(urls["spec"]).parent) made: list[str] = [] for role in EXTRACTED_LEGS: try: @@ -10275,9 +10548,10 @@ def _create_leg_remote(plan: Plan, names: dict, repositories: dict, print(f" gh {repositories[role]}") return bare = Path(urls[role]) + # Asked before either leg was made (#798); one there now was made since. if bare.exists(): raise Refusal( - "leg-remote-exists", f"{bare} already exists", + LEG_REMOTE_EXISTS, f"{bare} already exists", "Remediation: choose an empty --local-remote-dir, and a " "--work-dir the run that made it did not clone into (a fresh " "one, or none): `git clone` refuses a directory it already " @@ -10410,7 +10684,7 @@ def _push_the_leg(step: str, work: Path, url: str, tracking: str, "keeps there on the way to them. Then run `execute` again with fresh " "leg repositories (delete the two this run made, or choose an empty " "`--local-remote-dir`: a leg that already exists meets " - "`leg-remote-exists`, or a `gh repo create` that finds the name taken) " + "`leg-remote-exists`, on the disk or on GitHub) " "and an empty `--work-dir`, or none: a `--work-dir` this run was given " "still holds its clones of both legs, and the next run cannot clone " "into them.") @@ -11565,6 +11839,11 @@ def cmd_execute(args) -> int: plan.entries, source.tree(), source.commit) # ---- (a) the two legs' remotes ---------------------------------------- + # Both asked about first, and everything in their way refused at once, + # before either is made (#798): where `leg-remote-exists` always came, + # so every refusal above keeps its order. + _refuse_what_stands_in_the_legs_way(repositories, names, + args.local_remote_dir) _create_leg_remotes(plan, names, repositories, urls, tracking, local, work_root, source=source) @@ -13321,8 +13600,14 @@ def main(argv: list[str] | None = None) -> int: "submodule url the plan keeps in a leg is " "resolved from the bare repository `execute` " "would create there, as that run resolves it, " - "and not from GitHub's. Nothing is created " - "there.") + "and not from GitHub's, and `check` asks, as " + "`execute` asks before it makes either leg, " + "whether each leg's bare repository is already " + "there and whether this directory can be made " + "and written in. Nothing is created there. " + "Without it, `check` asks GitHub with `gh repo " + "view` whether either leg's name exists, and a " + "`gh` that cannot answer is a NOTE.") check.add_argument( "--work-dir", type=Path, default=None, help=_work_dir_help( @@ -13342,7 +13627,12 @@ def main(argv: list[str] | None = None) -> int: execute.add_argument("--source", default=None, help=SOURCE_OVERRIDE_HELP) execute.add_argument("--local-remote-dir", type=Path, default=None, help="create the legs as bare repositories here " - "instead of calling `gh` (the TEST path)") + "instead of calling `gh` (the TEST path). Both " + "are asked about before either is made: one " + "already there, or a directory that cannot be " + "made and written in, is refused, as `gh repo " + "view` finding either leg's name is without " + "it.") execute.add_argument("--allow-empty-leg", action="append", default=[], choices=EXTRACTED_LEGS, help="proceed even though NO path is assigned to this " diff --git a/docs/cli.md b/docs/cli.md index ad2cfa9b..874ce15c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -563,8 +563,13 @@ options: given (the TEST path): each relative submodule url the plan keeps in a leg is resolved from the bare repository `execute` would create there, as that run - resolves it, and not from GitHub's. Nothing is created - there. + resolves it, and not from GitHub's, and `check` asks, + as `execute` asks before it makes either leg, whether + each leg's bare repository is already there and + whether this directory can be made and written in. + Nothing is created there. Without it, `check` asks + GitHub with `gh repo view` whether either leg's name + exists, and a `gh` that cannot answer is a NOTE. --work-dir WORK_DIR where `check` works: an `org/repo` --source, or the plan's `source.repository` where the clone `plan` made is gone or no longer whole, is cloned there. Give the @@ -603,7 +608,11 @@ options: `plan-source-gone`. --local-remote-dir LOCAL_REMOTE_DIR create the legs as bare repositories here instead of - calling `gh` (the TEST path) + calling `gh` (the TEST path). Both are asked about + before either is made: one already there, or a + directory that cannot be made and written in, is + refused, as `gh repo view` finding either leg's name + is without it. --allow-empty-leg {spec,code} proceed even though NO path is assigned to this leg, seeding it from the shape's template. Unioned with the diff --git a/tests/gh_guard.py b/tests/gh_guard.py index 35c9f7a9..bbad26c3 100644 --- a/tests/gh_guard.py +++ b/tests/gh_guard.py @@ -36,6 +36,16 @@ status`. Any other argv -- `gh version`, `gh auth status --hostname x` -- is not a probe and is refused. +ONE MORE CALL IS ANSWERED (#798): `gh repo view / --json name`, +which `adopt-project.py check` asks of each leg repository it would create, +as `execute` does before it makes either. Every `check` in this suite that is +given no `--local-remote-dir` asks it twice, and refused, each such test +would fail. It is answered as a `gh` with no login answers it, the state the +other two answers describe: exit 4 and gh's own words (`NEEDS_A_LOGIN`), so +`check` prints, on every machine, the NOTE that it could not ask, and still +passes. It is logged as a probe. Only that exact shape is answered: `--json +visibility`, or any other field, and any other `repo view`, are refused. + STANDARD LIBRARY ONLY, and importable: `tests/conftest.py` and `tests/test_gh_guard.py` read the same table, the same record format and the same decision from here, so the guard and the check cannot disagree about @@ -46,6 +56,7 @@ import json import os +import re import shlex import sys from pathlib import Path @@ -77,6 +88,34 @@ ("auth", "status"): (1, "", NOT_LOGGED_IN + "\n"), } +#: gh's own words, on stderr with exit 4, for a command that needs a login on +#: a machine that has none (gh 2.x, outside CI): how `ASKS_WHETHER_IT_EXISTS` +#: is answered (#798). +NEEDS_A_LOGIN = ( + "To get started with GitHub CLI, please run: gh auth login\n" + "Alternatively, populate the GH_TOKEN environment variable with a GitHub " + "API authentication token.") +NO_LOGIN_EXIT = 4 + +#: `repo view / --json name`: one owner and one name, each a +#: word GitHub could name, and nothing else on the line. +ASKS_WHETHER_IT_EXISTS = re.compile(r"[A-Za-z0-9][\w.-]*/[\w.-]+") + + +def answer(argv) -> tuple | None: + """`(exit status, stdout, stderr)` for a call the guard answers: one of + the two `PROBES`, or whether a repository exists, as a `gh` with no + login answers it (#798). None for anything else, which is refused.""" + argv = list(argv) + probe = PROBES.get(tuple(argv)) + if probe is not None: + return probe + if (len(argv) == 5 and argv[:2] == ["repo", "view"] + and argv[3:] == ["--json", "name"] + and ASKS_WHETHER_IT_EXISTS.fullmatch(argv[2])): + return NO_LOGIN_EXIT, "", NEEDS_A_LOGIN + "\n" + return None + #: The refusal, ONE line on stderr. `{argv}` is the call as `shown` spells it. REFUSAL = ("gh guard (tests/conftest.py): no test may run the operator's gh, " "so `gh {argv}` was refused and logged; a test that means to run " @@ -88,8 +127,9 @@ def is_probe(argv) -> bool: - """Whether `argv` is one of the two `PROBES`, exactly.""" - return tuple(argv) in PROBES + """Whether `argv` is a call the guard answers (`answer`): one of the two + `PROBES`, exactly, or `repo view / --json name`.""" + return answer(argv) is not None def shown(argv) -> str: @@ -133,9 +173,10 @@ def failure(lines) -> str | None: if not named: return None return ("tests/conftest.py's gh guard logged " - f"{len(named)} line(s) that are not the machine row's two probes " - "-- calls to gh made with no stub of the test's own ahead of the " - "guard on PATH, each refused:\n" + "\n".join(named) + "\n" + f"{len(named)} line(s) that are not a call it answers (the " + "machine row's two probes, and `repo view / --json " + "name`) -- calls to gh made with no stub of the test's own ahead " + "of the guard on PATH, each refused:\n" + "\n".join(named) + "\n" "No test may reach the operator's gh. A test that means to run " "gh puts a stub of its own first on PATH, as " "tests/test_neutral_pin_validator.py does.") @@ -147,9 +188,9 @@ def main(argv) -> int: with open(log, "a", encoding="utf-8", newline="\n") as handle: handle.write(record(argv, os.environ.get("PYTEST_CURRENT_TEST", "")) + "\n") - answer = PROBES.get(tuple(argv)) - if answer is not None: - code, out, err = answer + answered = answer(argv) + if answered is not None: + code, out, err = answered sys.stdout.write(out) sys.stderr.write(err) return code diff --git a/tests/test_adopt_github_exits.py b/tests/test_adopt_github_exits.py index 2d34927d..7ce4a243 100644 --- a/tests/test_adopt_github_exits.py +++ b/tests/test_adopt_github_exits.py @@ -121,15 +121,18 @@ #: `gh` with no network. `repo create` makes a bare repository under #: $FAKE_GH_ROOT, which is what the rewritten `https://github.com/` URL then -#: names. Each command $FAKE_GH_REFUSES names (`|` between them; `pr create`, -#: or `repo edit ` for one repository alone) is refused the way a -#: token without the permission is, after writing the arguments it was given, -#: NUL-separated, to $FAKE_GH_ROOT/refused-arguments. `pr create` is refused -#: as GitHub refuses a base that is no branch of the repository (#479), and -#: records ` ` in $FAKE_GH_ROOT/opened when it opens one. -#: Every call records the token and the config directory it was run with in -#: $FAKE_GH_ROOT/environment, which are what a real `gh` would have been run -#: with, and its first three arguments, a line each, in $FAKE_GH_ROOT/calls. +#: names, and `repo view --json name`, which `execute` asks of +#: each leg before it makes either (#798), finds one there and gives gh's +#: not-found answer for any other. Each command $FAKE_GH_REFUSES names (`|` +#: between them; `pr create`, or `repo edit ` for one repository +#: alone) is refused the way a token without the permission is, after writing +#: the arguments it was given, NUL-separated, to +#: $FAKE_GH_ROOT/refused-arguments. `pr create` is refused as GitHub refuses a +#: base that is no branch of the repository (#479), and records ` +#: ` in $FAKE_GH_ROOT/opened when it opens one. Every call records the +#: token and the config directory it was run with in $FAKE_GH_ROOT/environment, +#: which are what a real `gh` would have been run with, and its first three +#: arguments, a line each, in $FAKE_GH_ROOT/calls. GH_SHIM = textwrap.dedent("""\ #!/bin/sh printf '%s\\n%s\\n' "$GH_TOKEN" "$GH_CONFIG_DIR" > "$FAKE_GH_ROOT/environment" @@ -147,6 +150,17 @@ done echo "HTTP 403: Resource not accessible by integration" >&2; exit 1 fi + case "$1 $2 $4 $5" in + "repo view --json name") + if [ -d "$FAKE_GH_ROOT/$3.git" ]; then + printf '{"name": "%s"}\\n' "${3#*/}" + else + said="GraphQL: Could not resolve to a Repository" + echo "$said with the name '$3'. (repository)" >&2 + exit 1 + fi + exit 0;; + esac case "$1 $2" in "repo create") git init -q --bare -b main "$FAKE_GH_ROOT/$3.git" && echo "https://github.com/$3";; diff --git a/tests/test_adopt_late_failure.py b/tests/test_adopt_late_failure.py index 19acf907..2dcf49d6 100644 --- a/tests/test_adopt_late_failure.py +++ b/tests/test_adopt_late_failure.py @@ -1267,7 +1267,7 @@ def test_a_live_leg_keeps_its_own_refusal_over_the_split_branch( def test_against_github_the_split_branch_is_asked_of_the_assembly( adopter, tmp_path): """Against GitHub nothing is on disk to look at, and the leg names are - taken or not by `gh repo create`; the branch is asked of the URL the + asked with `gh repo view` (#798); the branch is asked of the URL the split is pushed to, which a local path stands in for here.""" source = source_of(adopter, tmp_path) urls = {ROOT: str(source.path), diff --git a/tests/test_adopt_pins.py b/tests/test_adopt_pins.py index 01bc66b6..915c46af 100644 --- a/tests/test_adopt_pins.py +++ b/tests/test_adopt_pins.py @@ -148,6 +148,17 @@ def gh_calls(env: dict) -> str: return log.read_text(encoding=UTF8) if log.exists() else "" +def check_plan(plan: Path, **named) -> subprocess.CompletedProcess: + """`check --plan ` given the `--local-remote-dir` `execute` is + given here, `remotes` beside the plan: the test path, on which `check` + asks the disk and not GitHub whether each leg repository exists (#798). + So the stub's log holds only what a pin made `gh` asked, which is what + these tests read: `check` reads no network for a pin.""" + return run_script(ADOPT, "check", "--plan", str(plan), + "--local-remote-dir", str(plan.parent / "remotes"), + **named) + + def forge_objects(path: Path, objects: dict) -> Path: """`objects`, each oid and what the forge's GraphQL `object(oid:)` answers for it, written at `path` for the stub `gh` @@ -269,7 +280,7 @@ def adopted(request, tmp_path_factory, glass): for path, leg in ANSWERS: resolve(plan, path, leg) rmtree(readable) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=env) + checked = check_plan(plan, env=env) executed = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", "--local-remote-dir", str(base / "remotes"), "--work-dir", str(base / "work"), env=env) @@ -433,7 +444,7 @@ def adopted_as_text(request, tmp_path_factory): assert planned_run.returncode == 0, planned_run.stderr + planned_run.stdout for path, leg in ANSWERS: resolve(plan, path, leg) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=env) + checked = check_plan(plan, env=env) executed = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", "--local-remote-dir", str(base / "remotes"), "--work-dir", str(base / "work"), env=env) @@ -1070,7 +1081,7 @@ def test_check_passes_the_clone_a_plan_records_and_writes_no_path( with_pins(plan, "pins:\n" + pin_entry( commit=commit, digest=digest, source="local-clone (open\\u00a0Glass)")) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 0, checked.stdout + checked.stderr assert [line for line in checked.stdout.splitlines() if line.startswith(" write ")] == [ @@ -1107,7 +1118,7 @@ def test_the_pin_and_write_lines_say_how_and_not_where(source_repo, tmp_path, assert str(path) not in result.stdout + result.stderr for answered, leg in ANSWERS: resolve(out, answered, leg) - checked = run_script(ADOPT, "check", "--plan", str(out), env=gh_env) + checked = check_plan(out, env=gh_env) assert checked.returncode == 0, checked.stdout + checked.stderr assert [line for line in checked.stdout.splitlines() if line.startswith(" write ")] == [ @@ -1329,7 +1340,7 @@ def test_a_plan_whose_pin_execute_cannot_write_is_found_and_refused( block, said = cases[code][name] plan = planned(source_repo, tmp_path, PLANNED) with_pins(plan, block) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 1, checked.stdout + checked.stderr lines = [line for line in checked.stderr.splitlines() if line.startswith(f"FINDING {code}: ")] @@ -1429,7 +1440,7 @@ def test_a_pin_edited_after_plan_is_found_and_refused( commit, digest, code, recomputed = edits(glass)[edit] plan = planned(source_repo, tmp_path, PLANNED) with_pins(plan, read_from(recorded_clone, commit, digest)) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 1, checked.stdout + checked.stderr lines = [line for line in checked.stderr.splitlines() if line.startswith("FINDING ")] @@ -1473,7 +1484,7 @@ def test_a_pin_the_clone_recomputes_checks_ok(adopter, source_repo, tmp_path, typed = (commit.upper(), digest.upper()) if upper else (commit, digest) plan = planned(source_repo, tmp_path, PLANNED) with_pins(plan, read_from(recorded_clone, *typed)) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 0, checked.stdout + checked.stderr assert pin_notes(checked) == [ f"NOTE {ENTRY}: digest recomputes at {commit[:12]} in the clone " @@ -1498,7 +1509,7 @@ def test_a_pin_whose_clone_is_gone_is_said_not_rechecked(source_repo, with_pins(plan, read_from(recorded_clone, second, tree_digest(path, first))) rmtree(recorded_clone) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 0, checked.stdout + checked.stderr assert pin_notes(checked) == [ f"{NOT_RECHECKED}the clone `plan` read it from is not at " @@ -1525,7 +1536,7 @@ def test_a_pin_read_from_no_clone_is_said_not_rechecked(source_repo, with_pins(plan, "pins:\n" + pin_entry(commit=second, digest=tree_digest(path, first), source=source)) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 0, checked.stdout + checked.stderr assert pin_notes(checked) == [ f"{NOT_RECHECKED}{why}. {GATE_RECOMPUTES}"], checked.stdout @@ -1546,7 +1557,7 @@ def test_a_pin_read_from_a_partial_clone_is_said_not_rechecked( plan = planned(source_repo, tmp_path, PLANNED) with_pins(plan, read_from(recorded_clone, second, tree_digest(path, first))) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 0, checked.stdout + checked.stderr assert pin_notes(checked) == [ f"{NOT_RECHECKED}the clone at {recorded_clone} is a partial clone " @@ -1576,8 +1587,7 @@ def test_a_relative_clone_is_read_from_where_check_runs(source_repo, with_pins(plan, "pins:\n" + pin_entry(commit=second if edited else first, digest=tree_digest(path, first), source=f"local-clone ({name})")) - checked = run_script(ADOPT, "check", "--plan", str(plan), cwd=tmp_path, - env=gh_env) + checked = check_plan(plan, cwd=tmp_path, env=gh_env) assert checked.returncode == int(edited), checked.stdout + checked.stderr said = [line for line in (checked.stderr if edited else checked.stdout) .splitlines() if line.startswith(("FINDING ", f"NOTE {ENTRY}"))] @@ -1670,7 +1680,7 @@ def test_a_plan_with_a_whole_pin_checks_ok(source_repo, tmp_path, glass, and `execute` read no network for a pin, by design; #449).""" plan = planned(source_repo, tmp_path, PLANNED) with_pins(plan, "pins:\n" + a_good_pin(glass)) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 0, checked.stdout + checked.stderr assert f" write {PIN_FILE}: opensoft/openGlass @ {glass[1][:12]}" \ in checked.stdout @@ -1686,7 +1696,7 @@ def test_a_plan_with_no_pin_says_nothing_of_pins(source_repo, tmp_path, """What `check` says of a pin's digest is said of pins, and only where there is one.""" plan = planned(source_repo, tmp_path, PLANNED) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 0, checked.stdout + checked.stderr assert " write contracts/" not in checked.stdout assert "rechecked" not in checked.stdout @@ -1706,7 +1716,7 @@ def test_an_upper_cased_hand_edit_is_written_lower_cased(adopter, source_repo, plan = planned(source_repo, tmp_path, PLANNED) with_pins(plan, "pins:\n" + pin_entry(commit=commit.upper(), digest=digest.upper())) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 0, checked.stdout + checked.stderr assert (f" write {PIN_FILE}: opensoft/openGlass @ {commit[:12]} tree " f"{digest[:12]}… (local-clone)") in checked.stdout diff --git a/tests/test_gh_guard.py b/tests/test_gh_guard.py index 11a1014a..97b242bb 100644 --- a/tests/test_gh_guard.py +++ b/tests/test_gh_guard.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 -"""The session's `gh` guard (#296): it is reached, it answers the two probes, -its check fails a test that ran `gh`, and the doctor's `machine` row meets it. +"""The session's `gh` guard (#296): it is reached, it answers the two probes +and whether a repository exists (#798), its check fails a test that ran `gh`, +and the doctor's `machine` row meets it. `tests/conftest.py` puts the guard first on PATH for every child and checks, after each test, that the guard refused none of that test's calls. Each test @@ -28,8 +29,8 @@ from pathlib import Path from conftest import REPO, WINDOWS_SKIP, run_script -from gh_guard import (GUARD_EXIT, NOT_LOGGED_IN, VERSION_LINE, failure, - record) +from gh_guard import (GUARD_EXIT, NEEDS_A_LOGIN, NO_LOGIN_EXIT, + NOT_LOGGED_IN, VERSION_LINE, failure, record) DOCTOR = REPO / "shape-doctor.py" @@ -111,6 +112,32 @@ def test_the_two_probes_answer_as_a_gh_with_no_login(gh_guard, request): assert failure(lines) is None +def test_whether_a_repository_exists_is_answered_as_a_gh_with_no_login( + gh_guard, request): + """`adopt-project.py check` asks `gh repo view / --json + name` of each leg it would create (#798). The guard answers as a `gh` + with no login does, exit 4 and gh's own words, logs it as a probe, and + the per-test check passes it. Any other `repo view` is still refused.""" + asked = ["repo", "view", "testorg/Northwind-spec", "--json", "name"] + result = run([guard_launcher(gh_guard), *asked]) + assert result.returncode == NO_LOGIN_EXIT, result.stdout + result.stderr + assert result.stdout == "" + assert result.stderr == NEEDS_A_LOGIN + "\n" + lines = gh_guard.take() + assert [(kind, argv) for kind, argv, _ in calls(lines)] == [ + ("probe", asked)] + assert calls(lines)[0][2].startswith(this_test(request)) + assert failure(lines) is None + for near in (["repo", "view", "testorg/Northwind-spec"], + ["repo", "view", "testorg/Northwind", "--json", + "visibility"], + ["repo", "view", "--web", "--json", "name"], + ["repo", "view", "testorg/a/b", "--json", "name"], + ["repo", "create", "testorg/Northwind-spec", "--json", + "name"]): + assert failure([record(near, "")]) is not None, near + + def test_the_check_fails_on_a_refused_call_and_passes_on_probes_alone(): """The per-test check's decision, called directly on log lines. From 0bf6c7fff74b71f58cd082b920d5d692d1a500f1 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 14:19:43 +0000 Subject: [PATCH 03/13] adopt-project.py: a `--local-remote-dir` under a link loop reaches the question and is refused by name, in `execute` as in `check` (#798) `_remote_urls` resolved it with `Path.resolve`, which raised `RuntimeError: Symlink loop` out of `execute` before anything was asked, where `check` named it. Both now keep a path that cannot be resolved as given (`_the_remote_dir`), and `folder_in_the_way` names the loop. A path that resolves is unchanged. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 13 +++++++---- tests/test_adopt_execute_refusals.py | 35 ++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 6887243e..0171b96e 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -10285,9 +10285,11 @@ def _leg_remote_answers(repositories: dict, names: dict, def _the_remote_dir(local_remote_dir: Path) -> Path: - """`--local-remote-dir` as `_remote_urls` makes each leg's url of it, - resolved; made absolute as given where it cannot be resolved (a link - loop raises), for `folder_in_the_way` to name what is wrong with it.""" + """`--local-remote-dir` as each leg's url is made of it (`_remote_urls`) + and asked about: resolved, and made absolute as given where it cannot be + resolved. `Path.resolve` raised `RuntimeError` out of `execute` for a + link loop, a traceback before anything was asked (#798); + `folder_in_the_way` names it now, in `check` and `execute` alike.""" try: return local_remote_dir.resolve() except (OSError, RuntimeError): @@ -10969,8 +10971,9 @@ def _remote_urls(org: str, names: dict, local_remote_dir: Path | None, for role, name in names.items()} # NOT created yet: nothing exists on disk until the human has said yes, # so a refused run leaves the directory it would have used absent rather - # than empty. - remote_dir = local_remote_dir.resolve() + # than empty. One that cannot be resolved (a link loop) is kept as given, + # for the question about it to refuse by name (#798). + remote_dir = _the_remote_dir(local_remote_dir) urls = {role: str(remote_dir / f"{name}.git") for role, name in names.items()} urls["assembly"] = str(source.path) diff --git a/tests/test_adopt_execute_refusals.py b/tests/test_adopt_execute_refusals.py index 870aef14..49978b38 100644 --- a/tests/test_adopt_execute_refusals.py +++ b/tests/test_adopt_execute_refusals.py @@ -1396,10 +1396,19 @@ def unread(role: str, code: int, said: str) -> str: f"told: {told(role, code, said)}") +def as_named(path: Path) -> Path: + """`path` as `adopt-project.py` names a `--local-remote-dir`: resolved, + or made absolute where it cannot be (a link loop).""" + try: + return path.resolve() + except (OSError, RuntimeError): + return path.absolute() + + def cannot_hold(local_remote_dir: Path, here: Path, why: str) -> str: return (f"FINDING {FOLDER_REFUSED}: --local-remote-dir " - f"{spelled(local_remote_dir.resolve())} cannot hold the two leg " - f"repositories: {spelled(here.resolve())} {why}") + f"{spelled(as_named(local_remote_dir))} cannot hold the two leg " + f"repositories: {spelled(as_named(here))} {why}") @pytest.fixture @@ -1439,10 +1448,28 @@ def an_unwritable_directory(tmp_path: Path, unwritable_folder): return path, path, CANNOT_WRITE_IN -#: L1-72's three `--local-remote-dir`s, each refused by name. +def under_a_link_loop(tmp_path: Path, _unwritable): + """A folder above it that is a link loop: `Path.resolve` raised + `RuntimeError` out of `execute` before anything was asked. The reason is + the system's own, read from the loop rather than spelled here.""" + loop, back = tmp_path / "loop", tmp_path / "back" + try: + loop.symlink_to(back) + back.symlink_to(loop) + except OSError: + pytest.skip("this machine cannot make a symlink") + with pytest.raises(OSError) as looped: + os.stat(loop) + return (loop / REMOTES, loop, + f"cannot be used as a folder: {looped.value.strerror}") + + +#: L1-72's three `--local-remote-dir`s, each refused by name, and a link +#: loop above one. FOLDERS = {"a-file": a_file, "under-an-unwritable-parent": under_an_unwritable_parent, - "an-unwritable-directory": an_unwritable_directory} + "an-unwritable-directory": an_unwritable_directory, + "under-a-link-loop": under_a_link_loop} EACH_FOLDER = pytest.mark.parametrize("layout", sorted(FOLDERS)) #: The legs that already exist, each refused by name. EACH_EXISTING = pytest.mark.parametrize("existing", [(CODE,), (SPEC,), ROLES], From 99cc97e8b043aac494d00ec1e62dafaf1d0ddd08 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 18:53:47 +0000 Subject: [PATCH 04/13] tests: the gh stubs and stand-ins that meet `execute`'s and `check`'s new `gh repo view` of each leg answer it as GitHub does, and the notice's re-run words are asserted as they now read (#798) - tests/gh_guard.py answers `repo view / --json name` for an owner GitHub would refuse too (`a+b`, test_adopt_manifest_values.py). - test_adopt_leg_keys.py's stub and test_adopt_temp_work_roots.py's `gh_clones` give gh's not-found answer for a leg; `gh_clones` records only the other calls. - test_adopt_split_branch.py: the first gh call of a run that went on to the legs is now the spec leg's `gh repo view` (`TO_THE_LEGS`). - test_pin_annotated_tag.py and test_template_values_single_pass.py give `check` the `--local-remote-dir` their runs use, so a log of no gh call still says what it said. - test_adopt_filter_repo_failure.py asserts the notice's words for how a re-run is refused: before it makes either leg, on the disk and on GitHub alike. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/gh_guard.py | 7 ++++--- tests/test_adopt_filter_repo_failure.py | 10 +++++----- tests/test_adopt_leg_keys.py | 10 +++++++++- tests/test_adopt_split_branch.py | 13 +++++++++---- tests/test_adopt_temp_work_roots.py | 10 ++++++++-- tests/test_pin_annotated_tag.py | 10 +++++----- tests/test_template_values_single_pass.py | 6 +++++- 7 files changed, 45 insertions(+), 21 deletions(-) diff --git a/tests/gh_guard.py b/tests/gh_guard.py index bbad26c3..75841307 100644 --- a/tests/gh_guard.py +++ b/tests/gh_guard.py @@ -97,9 +97,10 @@ "API authentication token.") NO_LOGIN_EXIT = 4 -#: `repo view / --json name`: one owner and one name, each a -#: word GitHub could name, and nothing else on the line. -ASKS_WHETHER_IT_EXISTS = re.compile(r"[A-Za-z0-9][\w.-]*/[\w.-]+") +#: `repo view / --json name`: one owner and one name, as a +#: plan names them (an `org:` GitHub would refuse, `a+b`, included), with no +#: space or further `/`, and no option. +ASKS_WHETHER_IT_EXISTS = re.compile(r"[^\s/-][^\s/]*/[^\s/]+") def answer(argv) -> tuple | None: diff --git a/tests/test_adopt_filter_repo_failure.py b/tests/test_adopt_filter_repo_failure.py index 3aee0a04..542d2ed9 100644 --- a/tests/test_adopt_filter_repo_failure.py +++ b/tests/test_adopt_filter_repo_failure.py @@ -169,9 +169,9 @@ def test_a_failed_leg_is_printed_with_its_command_and_its_output( def test_a_failed_leg_names_both_leg_repositories_and_what_to_do( adopter, monkeypatch, capsys, tmp_path, seeded, command): """Both leg repositories exist by now, and one may even be pushed. The - human reads the failure, fixes it and re-runs; the re-run is refused by - `_create_leg_remotes`, so the notice says so BEFORE it happens, for both - kinds of remote, and names the two repositories to delete or to + human reads the failure, fixes it and re-runs; the re-run is refused as + `leg-remote-exists` before it makes either leg, on the disk and on + GitHub alike (#798), so the notice says so BEFORE it happens, and names the two repositories to delete or to replace. It says to fix what failed first: a cause that is still there fails the re-run the same way, after two more leg repositories (#188's review, F2).""" @@ -191,8 +191,8 @@ def test_a_failed_leg_names_both_leg_repositories_and_what_to_do( assert spelled(arguments["urls"][role]) in notice assert "--local-remote-dir" in notice assert LIVE_LEG in notice - assert "gh repo create" in notice - assert "already taken" in notice + assert ("before it makes either leg, under --local-remote-dir and " + "against GitHub alike") in notice assert "Once what failed above is fixed, re-run the adoption" in notice # The work directory too, which holds what this run built: a re-run that # kept it looped (#184 P2-1), and `execute` now refuses it. diff --git a/tests/test_adopt_leg_keys.py b/tests/test_adopt_leg_keys.py index 6f81afac..37e9efc2 100644 --- a/tests/test_adopt_leg_keys.py +++ b/tests/test_adopt_leg_keys.py @@ -68,9 +68,17 @@ #: A `gh` that creates a bare repository for `repo create`, takes `repo edit`, #: and logs every argument of a `pr create`, NUL-separated, so the body is read -#: back as it was given. Anything else is refused: it reaches nothing. +#: back as it was given. `repo view --json name`, which `execute` +#: asks of each leg before it makes either (#798), finds one under +#: $FAKE_GH_ROOT and gives gh's not-found answer for any other. Anything else +#: is refused: it reaches nothing. STUB_GH = textwrap.dedent("""\ #!/bin/sh + if [ "$1 $2 $4 $5" = "repo view --json name" ]; then + [ -d "$FAKE_GH_ROOT/$3.git" ] && exit 0 + echo "GraphQL: Could not resolve to a Repository with the name '$3'." >&2 + exit 1 + fi case "$1 $2" in "repo create") git init -q --bare -b main "$FAKE_GH_ROOT/$3.git" \\ diff --git a/tests/test_adopt_split_branch.py b/tests/test_adopt_split_branch.py index ce49c85d..6c1043f6 100644 --- a/tests/test_adopt_split_branch.py +++ b/tests/test_adopt_split_branch.py @@ -513,6 +513,11 @@ def a_github(tmp_path: Path, monkeypatch, branches=(SPLIT_BRANCH,)) -> Path: return bare +#: The first `gh` command a run that went on to the legs gives: whether the +#: spec leg's repository exists, asked before either is made (#798). +TO_THE_LEGS = ["gh", "repo", "view"] + + def no_gh(adopter, monkeypatch) -> list: """`run`, with every `gh` command it is given recorded and refused as `Proceeded`: nothing here may reach GitHub.""" @@ -558,7 +563,7 @@ def test_execute_goes_on_to_the_legs_when_github_has_no_such_branch( asked = no_gh(adopter, monkeypatch) with pytest.raises(Proceeded): adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) - assert asked[0][:3] == ["gh", "repo", "create"] + assert asked[0][:3] == TO_THE_LEGS def test_execute_refuses_a_repository_it_cannot_list(adopter, tmp_path, @@ -666,7 +671,7 @@ def test_a_github_run_is_not_refused_for_the_split_branch_in_the_source( asked = no_gh(adopter, monkeypatch) with pytest.raises(Proceeded): adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) - assert asked[0][:3] == ["gh", "repo", "create"] + assert asked[0][:3] == TO_THE_LEGS def test_a_plan_that_seeds_both_legs_is_not_asked_about_the_extraction( @@ -691,7 +696,7 @@ def test_a_plan_that_seeds_both_legs_is_not_asked_about_the_extraction( args.allow_empty_leg = ["spec", "code"] with pytest.raises(Proceeded): adopter.cmd_execute(args) - assert asked[0][:3] == ["gh", "repo", "create"] + assert asked[0][:3] == TO_THE_LEGS def with_origin(source: Path, bare: Path) -> None: @@ -841,7 +846,7 @@ def test_a_github_run_is_not_refused_for_a_branch_the_source_hides( args = execute_args(plan, tmp_path, local=False) with pytest.raises(Proceeded): adopter.cmd_execute(args) - assert asked[0][:3] == ["gh", "repo", "create"] + assert asked[0][:3] == TO_THE_LEGS def test_an_earlier_split_branch_the_source_hides_is_found_and_refused( diff --git a/tests/test_adopt_temp_work_roots.py b/tests/test_adopt_temp_work_roots.py index 076fb358..506bc735 100644 --- a/tests/test_adopt_temp_work_roots.py +++ b/tests/test_adopt_temp_work_roots.py @@ -139,14 +139,20 @@ def gh_clones(adopter, monkeypatch, origin: Path) -> list: """`gh repo clone REPOSITORY ` stood in for by a `git clone` of `origin`, its `origin` then set to the GitHub URL, as gh leaves it; a repository gh's own parser refuses -- a path -- fails as gh fails it, - and any other repository is not found. Returns the list each `gh` - call's arguments are recorded in.""" + and any other repository is not found. `gh repo view`, which `check` + and `execute` ask of each leg repository before either is made (#798), + finds none, as GitHub answers for a name it does not have, and is not + recorded. Returns the list each other `gh` call's arguments are + recorded in.""" real = adopter.run calls: list = [] def stand_in(args, *rest, **keywords): if args[0] != "gh": return real(args, *rest, **keywords) + if args[1:3] == ["repo", "view"]: + raise adopter.CommandFailed( + args, None, 1, "GraphQL: Could not resolve to a Repository") calls.append(list(args)) if not GH_REPOSITORY.fullmatch(args[3]): raise adopter.CommandFailed(args, None, 1, diff --git a/tests/test_pin_annotated_tag.py b/tests/test_pin_annotated_tag.py index 330e489c..d6ae7449 100644 --- a/tests/test_pin_annotated_tag.py +++ b/tests/test_pin_annotated_tag.py @@ -34,12 +34,12 @@ import pytest -from conftest import (ADOPT, SCAFFOLD, WINDOWS_SKIP, git, planned, +from conftest import (SCAFFOLD, WINDOWS_SKIP, git, planned, refused_before_any_leg, run_script) from test_adopt_pins import ( # noqa: F401 - ENTRY, ORG, PIN_FILE, PLANNED, PROJECT, adopter, asked_what, forge_objects, - gh_calls, gh_env, make_product, plan_with, read_from, shape_materialize, - tree_listing, with_pins, + ENTRY, ORG, PIN_FILE, PLANNED, PROJECT, adopter, asked_what, check_plan, + forge_objects, gh_calls, gh_env, make_product, plan_with, read_from, + shape_materialize, tree_listing, with_pins, ) from test_scaffold_pin_and_reuse import assert_pin_digest_recomputed from repo_shape import load_yaml, tree_digest @@ -288,7 +288,7 @@ def test_check_finds_a_recorded_id_that_is_no_commit( plan = planned(source_repo, tmp_path, PLANNED) with_pins(plan, read_from(tagged_clone, named, tree_digest(tagged.path, tagged.commit))) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 1, checked.stdout + checked.stderr lines = [line for line in checked.stderr.splitlines() if line.startswith("FINDING ")] diff --git a/tests/test_template_values_single_pass.py b/tests/test_template_values_single_pass.py index 3c113449..3f642b72 100644 --- a/tests/test_template_values_single_pass.py +++ b/tests/test_template_values_single_pass.py @@ -434,7 +434,11 @@ def test_adopt_records_each_free_text_value_as_typed(kind, tmp_path, gh_stub): "--elected-on", "2026-09-02", "--out", str(plan), env=env) assert planned.returncode == 0, planned.stderr + planned.stdout - checked = run_script(ADOPT, "check", "--plan", str(plan), env=env) + # Given the `--local-remote-dir` `execute` is given, `check` asks the + # disk and not GitHub whether each leg repository exists (#798). + checked = run_script(ADOPT, "check", "--plan", str(plan), + "--local-remote-dir", str(tmp_path / "remotes"), + env=env) assert checked.returncode == 0, checked.stderr + checked.stdout result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", "--local-remote-dir", str(tmp_path / "remotes"), From 9166f84f8a12be281ba0c9fbb81aae67425e5167 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 20:19:34 +0000 Subject: [PATCH 05/13] 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 06/13] 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 a13f2b227e9b28704174d9b4598ae0fc2f9afb34 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 23:42:49 +0000 Subject: [PATCH 07/13] adopt-project.py: a leg name is taken on GitHub only when `gh repo view --json nameWithOwner` answers with that very name, compared without case, so a name GitHub only redirects after a rename or a transfer is free, and an answer with no `nameWithOwner` to read is `adopt-remote-unreadable` (#798) gh's GraphQL follows renames and transfers, so `gh repo view ` exits 0 for a name that is free; any exit 0 was "taken", and the PR's own remedy (rename the other repository first) walked into it. The view now asks `nameWithOwner`; an answer naming another repository is a redirect, and the name is free. The test guard answers the new argv only, so it no longer answers scaffold-project.py's and family.py's `--json name` view (a new guard test shows theirs still refused); the four stubs answer `nameWithOwner`; new tests cover a rename, a transfer, another case and three unreadable answers. `"check"` in test_adopt_execute_refusals.py is a constant now, and its long parametrize line is wrapped. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 92 +++++++++++---- docs/cli.md | 6 +- tests/gh_guard.py | 37 +++--- tests/test_adopt_execute_refusals.py | 164 +++++++++++++++++++++++---- tests/test_adopt_github_exits.py | 14 +-- tests/test_adopt_leg_keys.py | 15 ++- tests/test_adopt_temp_work_roots.py | 13 ++- tests/test_gh_guard.py | 44 +++++-- 8 files changed, 295 insertions(+), 90 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 0be479b5..991223f4 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -93,10 +93,11 @@ it makes either leg (#798): given `--local-remote-dir`, whether each leg's bare repository is already there and whether that directory can be made and written in, and without it, with `gh - repo view`, whether GitHub already has either leg's name -- a - NOTE and no finding where gh cannot answer, so that `check` - stays usable offline. It prints what will happen and changes - nothing. + repo view --json nameWithOwner`, whether GitHub already has + 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. 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 @@ -127,7 +128,8 @@ And last, before it makes either leg, it refuses at once everything that stands in their way (#798), as `check` finds it: a leg repository already there, on the disk or on GitHub, a `--local-remote-dir` that cannot hold -them, and a `gh repo view` that fails otherwise than gh's not-found answer. +them, and a `gh repo view` that fails otherwise than gh's not-found answer +or answers with no `nameWithOwner` to read. THE NEW PROJECT MUST BE ABLE TO HOLD WHAT `execute` WRITES INTO IT (#261). `execute` puts the plan's values into the root's `project.yaml`, and the @@ -186,6 +188,7 @@ import contextlib import datetime as _dt import itertools +import json import os import re import shlex @@ -281,7 +284,8 @@ # What `execute` asks of both leg repositories before it makes either, and # `check` with it (#798): the folder `--local-remote-dir` names, asked as the # scaffold and `family.py` ask theirs, and gh's not-found answer, the one -# failure of `gh repo view` that means a repository is absent (#609). +# failure of `gh repo view` that means a repository is absent (#609); the +# other is an answer naming another repository, a redirect. from shape_materialize import ( # noqa: E402 CANNOT_WRITE_IN, GH_NOT_FOUND_WORDS, folder_in_the_way, ) @@ -9642,7 +9646,8 @@ def cmd_check(args) -> int: # 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`. + # `--local-remote-dir` and otherwise of GitHub, with `gh repo view + # --json nameWithOwner`. # A question gh cannot answer is a NOTE here, not a finding. legs, fixes, unasked = _leg_remote_findings(plan, names, local_remote_dir) @@ -10254,11 +10259,17 @@ def _refuse_a_split_branch_left_behind(source: Source, urls: dict, # then the code leg's, and then whether the directory can be made and # written in, asked as the scaffold and `family.py` ask theirs # (`folder_in_the_way`, #609), which makes nothing; -# - against GitHub, `gh repo view` of the spec leg's name and then the code -# leg's. ONLY GH'S NOT-FOUND ANSWER MEANS ABSENT, as #609 has it for the -# scaffold: a name that exists is `leg-remote-exists` before any `gh repo -# create`, and a view that fails otherwise is `adopt-remote-unreadable`, -# naming the command, its exit and gh's first line. +# - against GitHub, `gh repo view --json nameWithOwner` of the spec +# leg's name and then the code leg's. A NAME IS TAKEN ONLY WHEN GITHUB +# ANSWERS WITH IT: gh's GraphQL follows a rename or a transfer, so the +# answer counts only when its `nameWithOwner` is the name asked, compared +# without case as GitHub compares names; an answer naming another +# repository is a redirect, which `gh repo create` takes over, and the +# name is free. A name that exists is `leg-remote-exists` before any `gh +# repo create`. Gh's not-found answer means absent, as #609 has it for the +# scaffold; a view that fails otherwise, or answers with no +# `nameWithOwner` to read, is `adopt-remote-unreadable`, naming the +# command and what it said. # # WHERE: right before `_create_leg_remotes`, after the yes, where # `leg-remote-exists` was always refused. Every refusal before it keeps its @@ -10302,8 +10313,9 @@ def _refuse_a_split_branch_left_behind(source: Source, urls: dict, "Then run `check` and `execute` again.") UNREAD_FIX = ( "Only gh's own answer that a repository does not exist (exit 1, " - f"`{GH_NOT_FOUND_WORDS}`) lets `execute` create one; any other failure " - "says nothing about whether it is there. Check `gh auth status` (`gh " + f"`{GH_NOT_FOUND_WORDS}`), or one naming another repository that the " + "name redirects to, lets `execute` create one; any other answer says " + "nothing about whether it is there. Check `gh auth status` (`gh " "auth login` where it names a token that is missing, bad or expired), " "or let a rate limit or an outage pass, then run `execute` again.") FOLDER_FIX = ( @@ -10378,15 +10390,31 @@ def _first_said(output: str) -> str: else _spelled_character(char) for char in lines[0]) +def _name_with_owner(answer: str) -> str | None: + """The `nameWithOwner` a `gh repo view --json nameWithOwner` answer + names, or None for anything else: not JSON, not an object, no such + field, or one that is not a name.""" + try: + value = json.loads(answer) + except ValueError: + return None + value = value.get("nameWithOwner") if isinstance(value, dict) else None + return value if isinstance(value, str) and value else None + + def _in_the_way_on_github(role: str, repository: str) -> tuple | None: - """GitHub's answer for one leg's `repository`, by `gh repo view`: None - when gh says it does not exist, `leg-remote-exists` when it does, and - `adopt-remote-unreadable` when gh failed otherwise or could not be run, - which says nothing either way (#609).""" - command = ["gh", "repo", "view", repository, "--json", "name"] + """GitHub's answer for one leg's `repository`, by `gh repo view --json + nameWithOwner`: `leg-remote-exists` when GitHub answers with that very + name, compared without case as GitHub compares names; None when gh + says it does not exist (#609), or answers with another repository's + name, which is a redirect left by a rename or a transfer, a name `gh + repo create` takes over; and `adopt-remote-unreadable` when gh failed + otherwise, could not be run or answered with no `nameWithOwner` to + read, which says nothing either way.""" + command = ["gh", "repo", "view", repository, "--json", "nameWithOwner"] shown = " ".join(command) try: - run(command) + answer = run(command) except CommandFailed as exc: if exc.code == 1 and GH_NOT_FOUND_WORDS in exc.output: return None @@ -10394,9 +10422,20 @@ def _in_the_way_on_github(role: str, repository: str) -> tuple | None: except OSError as exc: how = f"`gh` could not be run: {exc}" else: - return (LEG_REMOTE_EXISTS, - f"{repository}, the {role} leg's repository, already exists " - f"on GitHub: `{shown}` finds it", ON_GITHUB_FIX, "") + found = _name_with_owner(answer) + if found is None: + how = (f"`{shown}` exited 0, and what it printed holds no " + f"`nameWithOwner`{_first_said(answer)}" if answer.strip() + else f"`{shown}` exited 0 and printed nothing") + elif found.lower() == repository.lower(): + return (LEG_REMOTE_EXISTS, + f"{repository}, the {role} leg's repository, already " + f"exists on GitHub: `{shown}` finds it", ON_GITHUB_FIX, + "") + else: + # GitHub followed a rename or a transfer away from this name: + # the name itself is free, and `gh repo create` takes it over. + return None named = f"{repository}, the {role} leg's repository," return (REMOTE_UNREADABLE, f"whether {named} already exists on GitHub cannot be told: {how}", @@ -13696,8 +13735,11 @@ def main(argv: list[str] | None = None) -> int: "there and whether this directory can be made " "and written in. Nothing is created there. " "Without it, `check` asks GitHub with `gh repo " - "view` whether either leg's name exists, and a " - "`gh` that cannot answer is a NOTE.") + "view --json nameWithOwner` whether either " + "leg's name exists (a name GitHub only " + "redirects to a repository renamed or moved " + "away does not), and a `gh` that cannot answer " + "is a NOTE.") check.add_argument( "--work-dir", type=Path, default=None, help=_work_dir_help( diff --git a/docs/cli.md b/docs/cli.md index e5273dbe..9a78c460 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -568,8 +568,10 @@ options: each leg's bare repository is already there and whether this directory can be made and written in. Nothing is created there. Without it, `check` asks - GitHub with `gh repo view` whether either leg's name - exists, and a `gh` that cannot answer is a NOTE. + GitHub with `gh repo view --json nameWithOwner` + whether either leg's name exists (a name GitHub only + redirects to a repository renamed or moved away does + not), and a `gh` that cannot answer is a NOTE. --work-dir WORK_DIR where `check` works: an `org/repo` --source, or the plan's `source.repository` where the clone `plan` made is gone or no longer whole, is cloned there. Give the diff --git a/tests/gh_guard.py b/tests/gh_guard.py index 75841307..022c7c34 100644 --- a/tests/gh_guard.py +++ b/tests/gh_guard.py @@ -36,15 +36,19 @@ status`. Any other argv -- `gh version`, `gh auth status --hostname x` -- is not a probe and is refused. -ONE MORE CALL IS ANSWERED (#798): `gh repo view / --json name`, -which `adopt-project.py check` asks of each leg repository it would create, -as `execute` does before it makes either. Every `check` in this suite that is -given no `--local-remote-dir` asks it twice, and refused, each such test -would fail. It is answered as a `gh` with no login answers it, the state the -other two answers describe: exit 4 and gh's own words (`NEEDS_A_LOGIN`), so -`check` prints, on every machine, the NOTE that it could not ask, and still -passes. It is logged as a probe. Only that exact shape is answered: `--json -visibility`, or any other field, and any other `repo view`, are refused. +ONE MORE CALL IS ANSWERED (#798): `gh repo view / --json +nameWithOwner`, which `adopt-project.py check` asks of each leg repository it +would create, as `execute` does before it makes either. Every `check` in this +suite that is given no `--local-remote-dir` asks it twice, and refused, each +such test would fail. It is answered as a `gh` with no login answers it, the +state the other two answers describe: exit 4 and gh's own words +(`NEEDS_A_LOGIN`), so `check` prints, on every machine, the NOTE that it +could not ask, and still passes. It is logged as a probe. Only that exact +shape is answered: `--json name`, `--json visibility` or any other field, +and any other `repo view`, are refused. SO IT ANSWERS ADOPT'S CALL ALONE: +`scaffold-project.py` and `family.py` ask whether their own repositories +exist with `--json name`, so a test of either that reaches gh with no stub +of its own is still failed at teardown (`tests/test_gh_guard.py`). STANDARD LIBRARY ONLY, and importable: `tests/conftest.py` and `tests/test_gh_guard.py` read the same table, the same record format and the @@ -97,8 +101,8 @@ "API authentication token.") NO_LOGIN_EXIT = 4 -#: `repo view / --json name`: one owner and one name, as a -#: plan names them (an `org:` GitHub would refuse, `a+b`, included), with no +#: `repo view / --json nameWithOwner`: one owner and one name, as +#: a plan names them (an `org:` GitHub would refuse, `a+b`, included), with no #: space or further `/`, and no option. ASKS_WHETHER_IT_EXISTS = re.compile(r"[^\s/-][^\s/]*/[^\s/]+") @@ -112,11 +116,12 @@ def answer(argv) -> tuple | None: if probe is not None: return probe if (len(argv) == 5 and argv[:2] == ["repo", "view"] - and argv[3:] == ["--json", "name"] + and argv[3:] == ["--json", "nameWithOwner"] and ASKS_WHETHER_IT_EXISTS.fullmatch(argv[2])): return NO_LOGIN_EXIT, "", NEEDS_A_LOGIN + "\n" return None + #: The refusal, ONE line on stderr. `{argv}` is the call as `shown` spells it. REFUSAL = ("gh guard (tests/conftest.py): no test may run the operator's gh, " "so `gh {argv}` was refused and logged; a test that means to run " @@ -129,7 +134,8 @@ def answer(argv) -> tuple | None: def is_probe(argv) -> bool: """Whether `argv` is a call the guard answers (`answer`): one of the two - `PROBES`, exactly, or `repo view / --json name`.""" + `PROBES`, exactly, or `repo view / --json nameWithOwner`. + """ return answer(argv) is not None @@ -176,8 +182,9 @@ def failure(lines) -> str | None: return ("tests/conftest.py's gh guard logged " f"{len(named)} line(s) that are not a call it answers (the " "machine row's two probes, and `repo view / --json " - "name`) -- calls to gh made with no stub of the test's own ahead " - "of the guard on PATH, each refused:\n" + "\n".join(named) + "\n" + "nameWithOwner`) -- calls to gh made with no stub of the test's " + "own ahead of the guard on PATH, each refused:\n" + + "\n".join(named) + "\n" "No test may reach the operator's gh. A test that means to run " "gh puts a stub of its own first on PATH, as " "tests/test_neutral_pin_validator.py does.") diff --git a/tests/test_adopt_execute_refusals.py b/tests/test_adopt_execute_refusals.py index 49978b38..d93ebc6c 100644 --- a/tests/test_adopt_execute_refusals.py +++ b/tests/test_adopt_execute_refusals.py @@ -53,6 +53,7 @@ PROJECT = "Northwind" UTF8 = "utf-8" +CHECK = "check" UNCOVERED = "plan-uncovered" REMOTES = "remotes" WORK = "work" @@ -200,7 +201,7 @@ def open_plan(adopter, world: World, text: str): def run_check(plan: Path): """`check --plan `, run as a person would.""" - return run_script(ADOPT, "check", "--plan", str(plan)) + return run_script(ADOPT, CHECK, "--plan", str(plan)) def check_findings(plan: Path) -> list[str]: @@ -968,7 +969,7 @@ def test_a_wrong_source_is_named_when_source_says_where_to_read_it(world): and its `commit:` is what `check` holds the tree to. On 5a5011d this was `AttributeError` from `source_commit`.""" text = with_key(world.answered, "source", "source: 5\n") - result = run_script(ADOPT, "check", "--plan", + result = run_script(ADOPT, CHECK, "--plan", str(write_edited(world, text)), "--source", str(world.source)) assert result.returncode == 1, result.stderr + result.stdout @@ -1362,7 +1363,7 @@ def repository(role: str) -> str: def view(role: str) -> list[str]: """The `gh` call that asks whether `role`'s leg repository exists.""" - return ["repo", "view", repository(role), "--json", "name"] + return ["repo", "view", repository(role), "--json", "nameWithOwner"] def leg_findings(text: str) -> list[str]: @@ -1381,19 +1382,25 @@ def on_disk(tmp_path: Path, role: str) -> str: def on_github(role: str) -> str: return (f"FINDING {LEG_EXISTS}: {repository(role)}, the {role} leg's " "repository, already exists on GitHub: `gh repo view " - f"{repository(role)} --json name` finds it") + f"{repository(role)} --json nameWithOwner` finds it") -def told(role: str, code: int, said: str) -> str: - """How a `gh repo view` that failed otherwise than not-found is named.""" - return (f"`gh repo view {repository(role)} --json name` exited {code}: " - f"{said}") +def told(role: str, what: str) -> str: + """How a `gh repo view` that failed otherwise than not-found, or + answered with no `nameWithOwner` to read, is named: the command, then + `what` it did.""" + return f"`gh repo view {repository(role)} --json nameWithOwner` {what}" -def unread(role: str, code: int, said: str) -> str: +def exited(code: int, said: str) -> str: + """`told`'s `what` for a `gh` that exited `code`, saying `said` first.""" + return f"exited {code}: {said}" + + +def unread(role: str, what: str) -> str: return (f"FINDING {REMOTE_UNREADABLE}: whether {repository(role)}, the " f"{role} leg's repository, already exists on GitHub cannot be " - f"told: {told(role, code, said)}") + f"told: {told(role, what)}") def as_named(path: Path) -> Path: @@ -1531,14 +1538,15 @@ def local_case(world: World, unwritable_folder, case: str) -> Path: return world.tmp / REMOTES -@pytest.mark.parametrize("case", ["code-exists", "both-exist", *sorted(FOLDERS)]) +@pytest.mark.parametrize("case", + ["code-exists", "both-exist", *sorted(FOLDERS)]) def test_check_given_the_same_local_remote_dir_prints_what_execute_refuses( adopter, world, unwritable, case): """#417's one verdict: `check --local-remote-dir` said `plan ok` in every case `execute` then refused. It prints each finding now, word for word as `execute`'s refusal lists it, and fails.""" local_remote_dir = local_case(world, unwritable, case) - checked = run_script(ADOPT, "check", "--plan", str(world.plan), + checked = run_script(ADOPT, CHECK, "--plan", str(world.plan), "--local-remote-dir", str(local_remote_dir)) assert checked.returncode == 1, checked.stderr + checked.stdout args = execute_args(world.tmp, world.plan) @@ -1551,10 +1559,12 @@ def test_check_given_the_same_local_remote_dir_prints_what_execute_refuses( #: A `gh` that logs each call, as JSON, to $GH_STUB_LOG and answers as GitHub #: would for the repositories in $GH_STUB_EXISTING: `repo view` of one there -#: finds it, and of any other says gh's not-found words, exit 1; `repo create` -#: makes any other, and of one there, or of one in $GH_STUB_HIDDEN (a -#: repository the token cannot see), says the name is taken. With -#: $GH_STUB_VIEW_FAILS (` `) every `repo view` fails so. +#: finds it, answering with its `nameWithOwner`, and of any other says gh's +#: not-found words, exit 1; `repo create` makes any other, and of one there, +#: or of one in $GH_STUB_HIDDEN (a repository the token cannot see), says the +#: name is taken. With $GH_STUB_VIEW_FAILS (` `) every `repo +#: view` fails so, and $GH_STUB_VIEW_ANSWERS (a JSON object) maps a +#: repository to what its `repo view` prints, exit 0. GH_STUB = textwrap.dedent("""\ import json, os, sys argv = sys.argv[1:] @@ -1562,12 +1572,16 @@ def test_check_given_the_same_local_remote_dir_prints_what_execute_refuses( log.write(json.dumps(argv) + "\\n") existing = os.environ.get("GH_STUB_EXISTING", "").split() failing = os.environ.get("GH_STUB_VIEW_FAILS") + answers = json.loads(os.environ.get("GH_STUB_VIEW_ANSWERS", "{}")) if argv[:2] == ["repo", "view"] and failing: code, said = failing.split(" ", 1) sys.stderr.write(said + "\\n") sys.exit(int(code)) + if argv[:2] == ["repo", "view"] and argv[2] in answers: + sys.stdout.write(answers[argv[2]] + "\\n") + sys.exit(0) if argv[:2] == ["repo", "view"] and argv[2] in existing: - print(json.dumps({"name": argv[2].split("/")[-1]})) + print(json.dumps({"nameWithOwner": argv[2]})) sys.exit(0) if argv[:2] == ["repo", "view"]: sys.stderr.write("GraphQL: Could not resolve to a Repository with " @@ -1679,7 +1693,7 @@ def test_a_view_that_fails_otherwise_is_refused_by_name( monkeypatch.setenv("GH_STUB_VIEW_FAILS", f"{code} {said}") refusal = refused_by(adopter, against_github(world)) assert refusal.code == REMOTE_UNREADABLE - assert leg_findings(refusal.detail) == [unread(role, code, said) + assert leg_findings(refusal.detail) == [unread(role, exited(code, said)) for role in ROLES] assert github.calls() == [view(SPEC), view(CODE)] @@ -1732,12 +1746,118 @@ def test_check_notes_a_view_that_fails_otherwise_and_still_passes( notes = [line for line in checked.stdout.splitlines() if line.startswith(COULD_NOT_ASK)] assert [role for role in ROLES - if any(told(role, code, said) in note for note in notes)] == \ + if any(told(role, exited(code, said)) in note + for note in notes)] == \ list(ROLES), notes assert len(notes) == len(ROLES), notes assert github.calls() == [view(SPEC), view(CODE)] +def answering(monkeypatch, role: str, printed: str) -> None: + """`gh repo view` of `role`'s leg repository exits 0 and prints + `printed`; the other leg's is not found.""" + monkeypatch.setenv("GH_STUB_VIEW_ANSWERS", + json.dumps({repository(role): printed})) + + +def named_as(name_with_owner: str) -> str: + """GitHub's answer to `--json nameWithOwner`, naming `name_with_owner`.""" + return json.dumps({"nameWithOwner": name_with_owner}) + + +#: Names GitHub answers a view of the code leg's name with when it only +#: redirects that name: a repository renamed away from it, and one moved +#: to another owner. +REDIRECTS = { + "renamed": f"{ORG}/{PROJECT}-code-old", + "transferred": f"elsewhere/{PROJECT}-code", +} + + +class LegsMade(Exception): + """Raised as `_create_leg_remotes` returns, to stop `execute` there.""" + + +@pytest.mark.parametrize("redirect", sorted(REDIRECTS)) +def test_a_name_github_only_redirects_is_free(adopter, world, github, + monkeypatch, redirect): + """gh's GraphQL follows a rename or a transfer, so `gh repo view` of a + name a repository was renamed or moved away from exits 0 and answers + with the name it has now. That name is free, and `gh repo create` takes + it over: `check` finds nothing, and `execute` goes on to both `repo + create` calls. Exit 0 alone is not "taken".""" + answering(monkeypatch, CODE, named_as(REDIRECTS[redirect])) + checked = run_check(world.plan) + assert checked.returncode == 0, checked.stderr + checked.stdout + assert leg_findings(checked.stderr) == [] + assert COULD_NOT_ASK not in checked.stdout, checked.stdout + assert github.calls() == [view(SPEC), view(CODE)] + github.log.unlink() + real = adopter._create_leg_remotes + + def then_stop(*args, **named): + real(*args, **named) + raise LegsMade + + monkeypatch.setattr(adopter, "_create_leg_remotes", then_stop) + monkeypatch.setattr(adopter, "_free_plan_hint_by_the_root", + lambda *_: None) + with pytest.raises(LegsMade): + adopter.cmd_execute(against_github(world)) + assert github.calls()[:2] == [view(SPEC), view(CODE)] + assert github.calls(3)[2:] == [["repo", "create", repository(SPEC)], + ["repo", "create", repository(CODE)]] + + +def test_a_name_github_answers_in_another_case_is_taken(adopter, world, + github, monkeypatch): + """GitHub compares names without case, so an answer naming the leg's + repository in other letters is that repository: taken, in `check` and + `execute` alike, with no `repo create`.""" + answering(monkeypatch, CODE, + named_as(f"{ORG.upper()}/{PROJECT.lower()}-CODE")) + checked = run_check(world.plan) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert leg_findings(checked.stderr) == [on_github(CODE)] + refusal = refused_by(adopter, against_github(world)) + assert refusal.code == LEG_EXISTS + assert leg_findings(refusal.detail) == [on_github(CODE)] + assert github.calls() == [view(SPEC), view(CODE)] * 2 + + +#: Answers, exit 0, that hold no `nameWithOwner` to read, and how each is +#: named after the command: `(printed, what)`. +UNREADABLE_ANSWERS = { + "not-json": (f"{PROJECT}-code", "exited 0, and what it printed holds no " + f"`nameWithOwner`: {PROJECT}-code"), + "no-field": (json.dumps({"name": f"{PROJECT}-code"}), + "exited 0, and what it printed holds no `nameWithOwner`: " + + json.dumps({"name": f"{PROJECT}-code"})), + "nothing": ("", "exited 0 and printed nothing"), +} + + +@pytest.mark.parametrize("answer", sorted(UNREADABLE_ANSWERS)) +def test_an_answer_with_no_name_to_read_is_unreadable(adopter, world, github, + monkeypatch, answer): + """An answer that is not JSON, or holds no `nameWithOwner`, says + nothing about the name: `execute` refuses it as + `adopt-remote-unreadable`, naming the command and what it printed, and + `check` notes it and passes, as for any view gh could not answer.""" + printed, what = UNREADABLE_ANSWERS[answer] + answering(monkeypatch, CODE, printed) + checked = run_check(world.plan) + assert checked.returncode == 0, checked.stderr + checked.stdout + assert leg_findings(checked.stderr) == [] + notes = [line for line in checked.stdout.splitlines() + if line.startswith(COULD_NOT_ASK)] + assert len(notes) == 1 and told(CODE, what) in notes[0], notes + refusal = refused_by(adopter, against_github(world)) + assert refusal.code == REMOTE_UNREADABLE + assert leg_findings(refusal.detail) == [unread(CODE, what)] + assert github.calls() == [view(SPEC), view(CODE)] * 2 + + def test_a_name_github_hides_still_gets_the_notice_once_the_spec_leg_exists( adopter, world, github, monkeypatch, capsys): """THE NOTICE OF #188 N4 STAYS for what can still fail after the first @@ -1747,9 +1867,9 @@ def test_a_name_github_hides_still_gets_the_notice_once_the_spec_leg_exists( monkeypatch.setenv("GH_STUB_HIDDEN", repository(CODE)) with pytest.raises(adopter.CommandFailed): adopter.cmd_execute(against_github(world)) - assert github.calls(3) == [view(SPEC)[:3], view(CODE)[:3], - ["repo", "create", repository(SPEC)], - ["repo", "create", repository(CODE)]] + assert github.calls()[:2] == [view(SPEC), view(CODE)] + assert github.calls(3)[2:] == [["repo", "create", repository(SPEC)], + ["repo", "create", repository(CODE)]] err = capsys.readouterr().err assert (f"{LATE_NOTICE}: `https://github.com/{repository(SPEC)}.git` " "exists, and nothing is pushed to it.") in err diff --git a/tests/test_adopt_github_exits.py b/tests/test_adopt_github_exits.py index 7ce4a243..e63b56eb 100644 --- a/tests/test_adopt_github_exits.py +++ b/tests/test_adopt_github_exits.py @@ -121,11 +121,11 @@ #: `gh` with no network. `repo create` makes a bare repository under #: $FAKE_GH_ROOT, which is what the rewritten `https://github.com/` URL then -#: names, and `repo view --json name`, which `execute` asks of -#: each leg before it makes either (#798), finds one there and gives gh's -#: not-found answer for any other. Each command $FAKE_GH_REFUSES names (`|` -#: between them; `pr create`, or `repo edit ` for one repository -#: alone) is refused the way a token without the permission is, after writing +#: names, and `repo view --json nameWithOwner`, which `execute` +#: asks of each leg before it makes either (#798), finds one there and gives +#: gh's not-found answer for any other. Each command $FAKE_GH_REFUSES names +#: (`|` between them; `pr create`, or `repo edit ` for one +#: repository alone) is refused the way a token without the permission is, after writing #: the arguments it was given, NUL-separated, to #: $FAKE_GH_ROOT/refused-arguments. `pr create` is refused as GitHub refuses a #: base that is no branch of the repository (#479), and records ` @@ -151,9 +151,9 @@ echo "HTTP 403: Resource not accessible by integration" >&2; exit 1 fi case "$1 $2 $4 $5" in - "repo view --json name") + "repo view --json nameWithOwner") if [ -d "$FAKE_GH_ROOT/$3.git" ]; then - printf '{"name": "%s"}\\n' "${3#*/}" + printf '{"nameWithOwner": "%s"}\\n' "$3" else said="GraphQL: Could not resolve to a Repository" echo "$said with the name '$3'. (repository)" >&2 diff --git a/tests/test_adopt_leg_keys.py b/tests/test_adopt_leg_keys.py index 37e9efc2..6d95a339 100644 --- a/tests/test_adopt_leg_keys.py +++ b/tests/test_adopt_leg_keys.py @@ -68,14 +68,17 @@ #: A `gh` that creates a bare repository for `repo create`, takes `repo edit`, #: and logs every argument of a `pr create`, NUL-separated, so the body is read -#: back as it was given. `repo view --json name`, which `execute` -#: asks of each leg before it makes either (#798), finds one under -#: $FAKE_GH_ROOT and gives gh's not-found answer for any other. Anything else -#: is refused: it reaches nothing. +#: back as it was given. `repo view --json nameWithOwner`, which +#: `execute` asks of each leg before it makes either (#798), finds one under +#: $FAKE_GH_ROOT, answering with its name as GitHub does, and gives gh's +#: not-found answer for any other. Anything else is refused: it reaches +#: nothing. STUB_GH = textwrap.dedent("""\ #!/bin/sh - if [ "$1 $2 $4 $5" = "repo view --json name" ]; then - [ -d "$FAKE_GH_ROOT/$3.git" ] && exit 0 + if [ "$1 $2 $4 $5" = "repo view --json nameWithOwner" ]; then + if [ -d "$FAKE_GH_ROOT/$3.git" ]; then + printf '{"nameWithOwner": "%s"}\\n' "$3"; exit 0 + fi echo "GraphQL: Could not resolve to a Repository with the name '$3'." >&2 exit 1 fi diff --git a/tests/test_adopt_temp_work_roots.py b/tests/test_adopt_temp_work_roots.py index 506bc735..61cc54cc 100644 --- a/tests/test_adopt_temp_work_roots.py +++ b/tests/test_adopt_temp_work_roots.py @@ -139,18 +139,19 @@ def gh_clones(adopter, monkeypatch, origin: Path) -> list: """`gh repo clone REPOSITORY ` stood in for by a `git clone` of `origin`, its `origin` then set to the GitHub URL, as gh leaves it; a repository gh's own parser refuses -- a path -- fails as gh fails it, - and any other repository is not found. `gh repo view`, which `check` - and `execute` ask of each leg repository before either is made (#798), - finds none, as GitHub answers for a name it does not have, and is not - recorded. Returns the list each other `gh` call's arguments are - recorded in.""" + and any other repository is not found. `gh repo view + --json nameWithOwner`, which `check` and `execute` ask of each leg + repository before either is made (#798), finds none, as GitHub answers + for a name it does not have, and is not recorded. Returns the list each + other `gh` call's arguments are recorded in.""" real = adopter.run calls: list = [] def stand_in(args, *rest, **keywords): if args[0] != "gh": return real(args, *rest, **keywords) - if args[1:3] == ["repo", "view"]: + if args[1:3] == ["repo", "view"] and \ + args[4:] == ["--json", "nameWithOwner"]: raise adopter.CommandFailed( args, None, 1, "GraphQL: Could not resolve to a Repository") calls.append(list(args)) diff --git a/tests/test_gh_guard.py b/tests/test_gh_guard.py index 97b242bb..93daaa28 100644 --- a/tests/test_gh_guard.py +++ b/tests/test_gh_guard.py @@ -115,10 +115,12 @@ def test_the_two_probes_answer_as_a_gh_with_no_login(gh_guard, request): def test_whether_a_repository_exists_is_answered_as_a_gh_with_no_login( gh_guard, request): """`adopt-project.py check` asks `gh repo view / --json - name` of each leg it would create (#798). The guard answers as a `gh` - with no login does, exit 4 and gh's own words, logs it as a probe, and - the per-test check passes it. Any other `repo view` is still refused.""" - asked = ["repo", "view", "testorg/Northwind-spec", "--json", "name"] + nameWithOwner` of each leg it would create (#798). The guard answers as + a `gh` with no login does, exit 4 and gh's own words, logs it as a + probe, and the per-test check passes it. Any other `repo view` is still + refused.""" + asked = ["repo", "view", "testorg/Northwind-spec", "--json", + "nameWithOwner"] result = run([guard_launcher(gh_guard), *asked]) assert result.returncode == NO_LOGIN_EXIT, result.stdout + result.stderr assert result.stdout == "" @@ -131,13 +133,41 @@ def test_whether_a_repository_exists_is_answered_as_a_gh_with_no_login( for near in (["repo", "view", "testorg/Northwind-spec"], ["repo", "view", "testorg/Northwind", "--json", "visibility"], - ["repo", "view", "--web", "--json", "name"], - ["repo", "view", "testorg/a/b", "--json", "name"], + ["repo", "view", "testorg/Northwind-spec", "--json", + "name"], + ["repo", "view", "--web", "--json", "nameWithOwner"], + ["repo", "view", "testorg/a/b", "--json", "nameWithOwner"], ["repo", "create", "testorg/Northwind-spec", "--json", - "name"]): + "nameWithOwner"]): assert failure([record(near, "")]) is not None, near +#: The argv `scaffold-project.py` (`remote_is_empty`) and `family.py` run to +#: ask whether a repository of their own exists, as each source spells it. +THEIR_VIEW = '["gh", "repo", "view", repository, "--json", "name"]' + + +def test_the_view_scaffold_and_family_ask_is_still_refused(gh_guard, + request): + """The guard answers adopt's `--json nameWithOwner` alone (#798). + `scaffold-project.py` and `family.py` ask `gh repo view / + --json name`, so a test of either that reaches gh with no stub of its + own is refused, exit 97, and failed at teardown, as before #798.""" + for tool in (REPO / "scaffold-project.py", REPO / "scripts" / "family.py"): + assert THEIR_VIEW in tool.read_text(encoding="utf-8"), tool + asked = ["repo", "view", "acme/Atlas", "--json", "name"] + result = run([guard_launcher(gh_guard), *asked]) + assert result.returncode == GUARD_EXIT, result.stdout + result.stderr + assert "tests/conftest.py" in result.stderr, result.stderr + lines = gh_guard.take() + assert [(kind, argv) for kind, argv, _ in calls(lines)] == [ + ("refused", asked)] + assert calls(lines)[0][2].startswith(this_test(request)) + message = failure(lines) + assert message is not None + assert "gh repo view acme/Atlas --json name " in message, message + + def test_the_check_fails_on_a_refused_call_and_passes_on_probes_alone(): """The per-test check's decision, called directly on log lines. From 748c8e6a2d6ad872bee266fad36fdb10cdb132d1 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 23:42:49 +0000 Subject: [PATCH 08/13] tests: one pins `check` runs as people run it, with no `--local-remote-dir`, and its gh log is the two leg views exactly; the split-branch tests compare the spec leg's whole view; repeated literals are constants and a long docstring line is wrapped (#798) Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_filter_repo_failure.py | 4 ++-- tests/test_adopt_late_failure.py | 21 ++++++++++--------- tests/test_adopt_pins.py | 27 +++++++++++++++++++++---- tests/test_adopt_split_branch.py | 14 +++++++------ 4 files changed, 45 insertions(+), 21 deletions(-) diff --git a/tests/test_adopt_filter_repo_failure.py b/tests/test_adopt_filter_repo_failure.py index 542d2ed9..b52c5759 100644 --- a/tests/test_adopt_filter_repo_failure.py +++ b/tests/test_adopt_filter_repo_failure.py @@ -171,8 +171,8 @@ def test_a_failed_leg_names_both_leg_repositories_and_what_to_do( """Both leg repositories exist by now, and one may even be pushed. The human reads the failure, fixes it and re-runs; the re-run is refused as `leg-remote-exists` before it makes either leg, on the disk and on - GitHub alike (#798), so the notice says so BEFORE it happens, and names the two repositories to delete or to - replace. It says to fix what failed first: a cause that is still there + GitHub alike (#798), so the notice says so BEFORE it happens, and names + the two repositories to delete or to replace. It says to fix what failed first: a cause that is still there fails the re-run the same way, after two more leg repositories (#188's review, F2).""" refuse_every_leg(adopter, monkeypatch, command, tmp_path) diff --git a/tests/test_adopt_late_failure.py b/tests/test_adopt_late_failure.py index 2dcf49d6..87e64441 100644 --- a/tests/test_adopt_late_failure.py +++ b/tests/test_adopt_late_failure.py @@ -94,6 +94,9 @@ COPY_TREE = "copy_tree" TREE_OF = "_tree_of" PLAN_FLAG = "--plan" +LEG_REMOTE_EXISTS = "leg-remote-exists" +BARE_FLAG = "--bare" +EXECUTE = "execute" #: A source small enough that `plan` asks no question, so `execute` runs on #: the plan as written. @@ -375,7 +378,7 @@ def test_a_split_that_fails_names_its_step_and_both_pushed_legs( for role in LEGS: assert spelled(arguments["urls"][role]) in notice assert BOTH_PUSHED in notice - assert "leg-remote-exists" in notice + assert LEG_REMOTE_EXISTS in notice assert FIX_FIRST in notice # Read off the disk: the split's clone is there, and no leg's, because # the legs here are stand-ins that were never built in it. @@ -919,7 +922,7 @@ def unreadable(*_args): arguments = legs_arguments(tmp_path, source=source_of(adopter, tmp_path)) arguments["seeded"] = seeded for role in LEGS: - git("init", "-q", "--bare", "-b", "main", arguments["urls"][role], + git("init", "-q", BARE_FLAG, "-b", "main", arguments["urls"][role], cwd=tmp_path) assert adopter._build_the_legs(**arguments) is None @@ -1002,7 +1005,7 @@ def test_main_flushes_stdout_before_a_failure_no_step_reported( events, stderr = recorded_streams(monkeypatch) said, unsaid = scenario(adopter, monkeypatch, tmp_path) - assert adopter.main(["execute", PLAN_FLAG, "adoption-plan.yaml"]) == 2 + assert adopter.main([EXECUTE, PLAN_FLAG, "adoption-plan.yaml"]) == 2 err = "".join(stderr.written) assert flushed_first(events) @@ -1132,7 +1135,7 @@ def test_a_refused_leg_push_offers_an_adoption_its_one_exit( `main` as a ruleset does.""" monkeypatch.setattr(adopter, COPY_TREE, seed_one_file) remote = tmp_path / REMOTES / f"{ASSEMBLY}-{SPEC}.git" - git("init", "-q", "--bare", "-b", "main", str(remote), cwd=tmp_path) + git("init", "-q", BARE_FLAG, "-b", "main", str(remote), cwd=tmp_path) write_refusing_hook(remote / "hooks", MAIN_REF) with pytest.raises(adopter.CommandFailed): @@ -1261,7 +1264,7 @@ def test_a_live_leg_keeps_its_own_refusal_over_the_split_branch( with pytest.raises(adopter.Refusal) as caught: adopter.cmd_execute(execute_args(tmp_path, plan)) - assert caught.value.code == "leg-remote-exists" + assert caught.value.code == LEG_REMOTE_EXISTS def test_against_github_the_split_branch_is_asked_of_the_assembly( @@ -1329,12 +1332,12 @@ def test_a_code_leg_made_after_the_question_still_gets_the_notice( tree=SMALL_TREE, edits=())) code_leg = tmp_path / REMOTES / f"{ASSEMBLY}-{CODE}.git" after_the_question(adopter, monkeypatch, lambda: git( - "init", "-q", "--bare", str(code_leg), cwd=tmp_path)) + "init", "-q", BARE_FLAG, str(code_leg), cwd=tmp_path)) with pytest.raises(adopter.Refusal) as caught: adopter.cmd_execute(execute_args(tmp_path, plan)) - assert caught.value.code == "leg-remote-exists" + assert caught.value.code == LEG_REMOTE_EXISTS err = capsys.readouterr().err assert f"{REFUSED} creating the {CODE} leg repository. " in err spec_leg = (tmp_path / REMOTES).resolve() / f"{ASSEMBLY}-{SPEC}.git" @@ -1356,7 +1359,7 @@ def test_a_local_remote_dir_that_cannot_be_made_after_the_question_is_refused( after_the_question(adopter, monkeypatch, lambda: remotes.write_text( "put here since\n", encoding=UTF8)) - assert adopter.main(["execute", PLAN_FLAG, str(plan), "--yes", + assert adopter.main([EXECUTE, PLAN_FLAG, str(plan), "--yes", "--local-remote-dir", str(remotes), "--work-dir", str(tmp_path / WORK)]) == 2 @@ -1607,7 +1610,7 @@ def adopt_with_hook(tmp_path: Path, ref_pattern: str): def execute(plan: Path, remotes: Path, work: Path, env: dict | None = None, merge_stderr: bool = False) -> subprocess.CompletedProcess: - return run_script(ADOPT, "execute", PLAN_FLAG, str(plan), "--yes", + return run_script(ADOPT, EXECUTE, PLAN_FLAG, str(plan), "--yes", "--local-remote-dir", str(remotes), "--work-dir", str(work), env=env, merge_stderr=merge_stderr) diff --git a/tests/test_adopt_pins.py b/tests/test_adopt_pins.py index 440c6ce3..a89a60b2 100644 --- a/tests/test_adopt_pins.py +++ b/tests/test_adopt_pins.py @@ -153,12 +153,27 @@ def check_plan(plan: Path, **named) -> subprocess.CompletedProcess: given here, `remotes` beside the plan: the test path, on which `check` asks the disk and not GitHub whether each leg repository exists (#798). So the stub's log holds only what a pin made `gh` asked, which is what - these tests read: `check` reads no network for a pin.""" + these tests read: `check` reads no network for a pin. One test runs + `check` as people run it, with no flag (`check_on_github`).""" return run_script(ADOPT, "check", "--plan", str(plan), "--local-remote-dir", str(plan.parent / "remotes"), **named) +def check_on_github(plan: Path, **named) -> subprocess.CompletedProcess: + """`check --plan ` with no `--local-remote-dir`, as people run it: + it asks GitHub whether each leg repository exists (#798), the two calls + `LEG_VIEWS` names, and no more for a pin.""" + return run_script(ADOPT, "check", "--plan", str(plan), **named) + + +#: The two `gh` calls, as the stub logs them, that a `check` with no +#: `--local-remote-dir` makes for a plan of `PROJECT`: whether each leg's +#: repository exists, the spec leg's first. +LEG_VIEWS = [f"repo view {ORG}/{PROJECT}-{role} --json nameWithOwner" + for role in ("spec", "code")] + + def forge_objects(path: Path, objects: dict) -> Path: """`objects`, each oid and what the forge's GraphQL `object(oid:)` answers for it, written at `path` for the stub `gh` @@ -1086,7 +1101,9 @@ def test_the_pin_and_write_lines_say_how_and_not_where(source_repo, tmp_path, digest was read, `local-clone`, and not where, because the path is this machine's own and `execute` writes into the pin file what `check` prints there (#540). The one line of either that names the clone is - #449's NOTE that the digest recomputes there.""" + #449's NOTE that the digest recomputes there. `check` runs here as + people run it, with no `--local-remote-dir`, and asks `gh` the two leg + questions alone (#798).""" path, commit, _ = glass digest = tree_digest(path, commit) out = tmp_path / "plan.yaml" @@ -1102,7 +1119,7 @@ def test_the_pin_and_write_lines_say_how_and_not_where(source_repo, tmp_path, assert str(path) not in result.stdout + result.stderr for answered, leg in ANSWERS: resolve(out, answered, leg) - checked = check_plan(out, env=gh_env) + checked = check_on_github(out, env=gh_env) assert checked.returncode == 0, checked.stdout + checked.stderr assert [line for line in checked.stdout.splitlines() if line.startswith(" write ")] == [ @@ -1113,7 +1130,9 @@ def test_the_pin_and_write_lines_say_how_and_not_where(source_repo, tmp_path, f"NOTE {ENTRY}: digest recomputes at {commit[:12]} in the clone " f"`plan` read it from, {path}."], checked.stdout assert str(path) not in checked.stderr - assert gh_calls(gh_env) == "" + # The two leg questions, and nothing a pin asked: `check` reads no + # network for a pin when it runs as people run it, with no flag. + assert gh_calls(gh_env).splitlines() == LEG_VIEWS # --- what `check` finds, and `execute` refuses before any leg exists --------- diff --git a/tests/test_adopt_split_branch.py b/tests/test_adopt_split_branch.py index 6c1043f6..b70e3b28 100644 --- a/tests/test_adopt_split_branch.py +++ b/tests/test_adopt_split_branch.py @@ -514,8 +514,10 @@ def a_github(tmp_path: Path, monkeypatch, branches=(SPLIT_BRANCH,)) -> Path: #: The first `gh` command a run that went on to the legs gives: whether the -#: spec leg's repository exists, asked before either is made (#798). -TO_THE_LEGS = ["gh", "repo", "view"] +#: spec leg's repository exists, asked before either is made (#798), whole, +#: so that another `repo view` asked earlier cannot pass for it. +TO_THE_LEGS = ["gh", "repo", "view", f"{ORG}/{PROJECT}-spec", "--json", + "nameWithOwner"] def no_gh(adopter, monkeypatch) -> list: @@ -563,7 +565,7 @@ def test_execute_goes_on_to_the_legs_when_github_has_no_such_branch( asked = no_gh(adopter, monkeypatch) with pytest.raises(Proceeded): adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) - assert asked[0][:3] == TO_THE_LEGS + assert asked[0] == TO_THE_LEGS def test_execute_refuses_a_repository_it_cannot_list(adopter, tmp_path, @@ -671,7 +673,7 @@ def test_a_github_run_is_not_refused_for_the_split_branch_in_the_source( asked = no_gh(adopter, monkeypatch) with pytest.raises(Proceeded): adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) - assert asked[0][:3] == TO_THE_LEGS + assert asked[0] == TO_THE_LEGS def test_a_plan_that_seeds_both_legs_is_not_asked_about_the_extraction( @@ -696,7 +698,7 @@ def test_a_plan_that_seeds_both_legs_is_not_asked_about_the_extraction( args.allow_empty_leg = ["spec", "code"] with pytest.raises(Proceeded): adopter.cmd_execute(args) - assert asked[0][:3] == TO_THE_LEGS + assert asked[0] == TO_THE_LEGS def with_origin(source: Path, bare: Path) -> None: @@ -846,7 +848,7 @@ def test_a_github_run_is_not_refused_for_a_branch_the_source_hides( args = execute_args(plan, tmp_path, local=False) with pytest.raises(Proceeded): adopter.cmd_execute(args) - assert asked[0][:3] == TO_THE_LEGS + assert asked[0] == TO_THE_LEGS def test_an_earlier_split_branch_the_source_hides_is_found_and_refused( From 70801c91a75177062b812f9734c0b615320a8232 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 02:30:06 +0000 Subject: [PATCH 09/13] tests: the argument of `cmd_execute` is built before the `with pytest.raises` in three tests, and a composite assert is split in two, as SonarCloud's first run on #861 asked (S5778, S9073) (#798) SonarCloud's first analysis of this PR found four issues in its tests: three S5778 (a call that can raise, `against_github(world)` or `execute_args(tmp_path, plan)`, inside `pytest.raises`) and one S9073 (`assert len(notes) == 1 and told(CODE, what) in notes[0]`). Each argument is now built on the line before the `with`, and the assert is two. Four tests change: `test_a_name_github_only_redirects_is_free`, `test_an_answer_with_no_name_to_read_is_unreadable` and `test_a_name_github_hides_still_gets_the_notice_once_the_spec_leg_exists` in test_adopt_execute_refusals.py, and `test_a_code_leg_made_after_the_question_still_gets_the_notice` in test_adopt_late_failure.py. No test asserts anything new, and the four older copies of those two lines in test_adopt_late_failure.py are left as they were. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_adopt_execute_refusals.py | 9 ++++++--- tests/test_adopt_late_failure.py | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_adopt_execute_refusals.py b/tests/test_adopt_execute_refusals.py index d93ebc6c..18ea8eaf 100644 --- a/tests/test_adopt_execute_refusals.py +++ b/tests/test_adopt_execute_refusals.py @@ -1802,8 +1802,9 @@ def then_stop(*args, **named): monkeypatch.setattr(adopter, "_create_leg_remotes", then_stop) monkeypatch.setattr(adopter, "_free_plan_hint_by_the_root", lambda *_: None) + github_args = against_github(world) with pytest.raises(LegsMade): - adopter.cmd_execute(against_github(world)) + adopter.cmd_execute(github_args) assert github.calls()[:2] == [view(SPEC), view(CODE)] assert github.calls(3)[2:] == [["repo", "create", repository(SPEC)], ["repo", "create", repository(CODE)]] @@ -1851,7 +1852,8 @@ def test_an_answer_with_no_name_to_read_is_unreadable(adopter, world, github, assert leg_findings(checked.stderr) == [] notes = [line for line in checked.stdout.splitlines() if line.startswith(COULD_NOT_ASK)] - assert len(notes) == 1 and told(CODE, what) in notes[0], notes + assert len(notes) == 1, notes + assert told(CODE, what) in notes[0], notes refusal = refused_by(adopter, against_github(world)) assert refusal.code == REMOTE_UNREADABLE assert leg_findings(refusal.detail) == [unread(CODE, what)] @@ -1865,8 +1867,9 @@ def test_a_name_github_hides_still_gets_the_notice_once_the_spec_leg_exists( found, so both questions pass, and `repo create` then finds the code leg's name taken once the spec leg's repository exists.""" monkeypatch.setenv("GH_STUB_HIDDEN", repository(CODE)) + github_args = against_github(world) with pytest.raises(adopter.CommandFailed): - adopter.cmd_execute(against_github(world)) + adopter.cmd_execute(github_args) assert github.calls()[:2] == [view(SPEC), view(CODE)] assert github.calls(3)[2:] == [["repo", "create", repository(SPEC)], ["repo", "create", repository(CODE)]] diff --git a/tests/test_adopt_late_failure.py b/tests/test_adopt_late_failure.py index 87e64441..261f4c98 100644 --- a/tests/test_adopt_late_failure.py +++ b/tests/test_adopt_late_failure.py @@ -1334,8 +1334,9 @@ def test_a_code_leg_made_after_the_question_still_gets_the_notice( after_the_question(adopter, monkeypatch, lambda: git( "init", "-q", BARE_FLAG, str(code_leg), cwd=tmp_path)) + run_args = execute_args(tmp_path, plan) with pytest.raises(adopter.Refusal) as caught: - adopter.cmd_execute(execute_args(tmp_path, plan)) + adopter.cmd_execute(run_args) assert caught.value.code == LEG_REMOTE_EXISTS err = capsys.readouterr().err From 495d4b330ac851b730d57bcef06fe62c4a5d1995 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 02:28:18 +0000 Subject: [PATCH 10/13] 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: From 66fc8f6bd3cdb68421aa719ed5acd5816c53b055 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 02:53:31 +0000 Subject: [PATCH 11/13] family.py, adopt-project.py and the four tools' `--org`: `init` asks GitHub's limits on the topic, the `--org` and the holder's name before any repository exists, `check` finds what `execute` refuses from one list, and an `--org` GitHub gives no organisation (`.`, a trailing hyphen, `--`) is refused where GitHub is asked (#863) `family.py init` set `xf-project-` last, after `gh repo create` and the push, and asked none of GitHub's limits: a family of 40 characters made and pushed its holder and then met GitHub's refusal of the topic. `_checked_family_identity` now asks them, where GitHub is asked and before `_creator_name` and any `gh` call, so `--dry-run` refuses the same: `family-name-too-long` (100), `family-org-too-long` (39), `family-bad-org` and `family-topic-too-long` (50), with #859's helpers and the scaffold's words. `init --help` says the three bounds; `docs/cli.md` is regenerated. `adopt-project.py check` asked none of what `execute` asks of the names, the organisation and the topic, the disk's 255 bytes included, so it said `plan ok` over a plan `execute` refused. `_what_cannot_be_held` is now the one list: `execute` refuses its first refusal in `_repository_urls`, and `check` finds every one (`_held_findings`) under the same code, before #861's leg question, which it no longer asks after one. The topic question moves from just before `_confirm` into that list, so `execute` asks it with the names and the organisation. `refuse_an_owner_github_cannot_spell` (shape_materialize.py) refuses an owner holding a character outside letters, digits, `_` and `-`, ending in a hyphen or holding two in a row: `scaffold-bad-org`, `adopt-bad-org` and `family-bad-org`, asked beside the 39 and only without `--local-remote-dir`; `setup-project.py` takes the scaffold's answer at its step (5). `_` is let by until a real account is probed. No pinned file changes. GitHub's numbers and characters are from its documentation and are not measured here. Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 146 +++- docs/cli.md | 13 +- scaffold-project.py | 26 +- scripts/family.py | 120 +++- scripts/shape_materialize.py | 52 ++ tests/test_adopt_manifest_values.py | 23 +- tests/test_github_limits_init_check_org.py | 751 +++++++++++++++++++++ tests/test_reader_refusals_named.py | 9 +- tests/test_scaffold_manifest_values.py | 7 +- 9 files changed, 1101 insertions(+), 46 deletions(-) create mode 100644 tests/test_github_limits_init_check_org.py diff --git a/adopt-project.py b/adopt-project.py index 4eb89120..22eda4f3 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -97,7 +97,12 @@ 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. 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 @@ -146,7 +151,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 @@ -271,6 +280,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 @@ -9656,14 +9667,24 @@ def cmd_check(args) -> int: except Refusal as exc: findings.append(f"FINDING {exc.code}: {exc.detail}") 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_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, which + # `execute` refuses first. # 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 held else _leg_remote_findings( + plan, names, local_remote_dir)) findings.extend(legs) remediation.extend(fixes) notes = [*notes, *unasked] @@ -10507,6 +10528,24 @@ def _leg_remote_findings(plan: Plan, names: dict, return findings, list(dict.fromkeys(fixes)), notes +def _held_findings(plan: Plan, names: dict, local_remote_dir: Path | None, + naming: NamingPolicy) -> tuple[list[str], list[str]]: + """`check`'s `(findings, remediation)` 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. 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))) + + 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 @@ -11036,17 +11075,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 @@ -11072,13 +11157,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 @@ -11089,6 +11168,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} " @@ -11120,13 +11205,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 @@ -12007,7 +12108,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) @@ -12029,9 +12131,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 eefd4fe5..b958639e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -845,9 +845,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 9c49845b..18f06617 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 @@ -119,6 +121,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). @@ -866,16 +870,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, @@ -887,6 +897,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 faa7ba4d..00a3cd63 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" @@ -2011,6 +2033,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. @@ -2037,6 +2136,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 @@ -8083,13 +8185,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 8de94442..4e8da90e 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -5138,3 +5138,55 @@ 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 + raise Refusal( + refusal, + f"{named} is {owner!r}, 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..fe043f8d --- /dev/null +++ b/tests/test_github_limits_init_check_org.py @@ -0,0 +1,751 @@ +# 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" + +#: 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") + + +# --- 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.""" + with pytest.raises(ProcessStarted, match="user.name"): + family.main(init_args(tmp_path, *flags)) + err = capsys.readouterr().err + assert "too-long" not in err and "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) + with pytest.raises(Refusal) as caught: + adopter._repository_urls(SimpleNamespace(local_remote_dir=None), + _Plan(), names_of(NORTHWIND), 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."]) + 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, and + asks GitHub nothing: the leg question comes after, and `execute` never + gets that far. `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 == [] + 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")] + + +# --- 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 From 99afbc3dc752ac4d6a626ab72f93fd0987699ffc Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 04:00:08 +0000 Subject: [PATCH 12/13] adopt-project.py and shape_materialize.py: `check` asks #861's leg question beside a topic finding and skips it only after a name or an `org:` finding, whose fix renames both legs, and a misspelled `org:` past GitHub's 39 is shown cut (#863, #871's review) #871's first head skipped #861's leg question after any of #863's findings, a topic-only one too: a plan whose id was 40 characters and whose code leg was already taken on GitHub listed `adopt-topic-too-long` alone, and `leg-remote-exists` only once the id was mended, though an id names no repository (RV871 S1, lane 3's fix (a)). `_held_findings` now gives the codes too, and `check` skips the leg question only after `adopt-name-too-long`, `adopt-org-too-long` or `adopt-bad-org` (`RENAMES_THE_LEGS`): a shorter --project or another `org:` changes `/-spec` and `-code`, so the question would be asked of names the mended plan does not use. `refuse_an_owner_github_cannot_spell` shows a value past GitHub's 39 as `refuse_an_owner_github_cannot_have` shows it, cut with `_both_ends` and its length, so `check` no longer prints all of a 255-character `org:` (N3). Tests: `[id]` and `[derived-id]` expect the two leg views; a topic and a taken leg are found in one round (S1); one `org:` both too long and misspelled is listed length first by `check` and refused length first by `execute`, `init`, the scaffold and setup's step (5) (N2); a cut value (N3); and a plan whose names do not classify gets no held finding (N5). The arguments are built before each `pytest.raises` and the composite assert is split (S2: S5778, S9073). Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 58 +++++-- scripts/shape_materialize.py | 10 +- tests/test_github_limits_init_check_org.py | 182 ++++++++++++++++++++- 3 files changed, 224 insertions(+), 26 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 22eda4f3..cb125a76 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -101,7 +101,9 @@ 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. It prints what will happen and changes + `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 @@ -9672,19 +9674,29 @@ def cmd_check(args) -> int: # 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_findings(plan, names, local_remote_dir, - naming) + 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`. Not asked after a finding above, which - # `execute` refuses first. + # --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 = (([], [], []) if held else _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] @@ -10528,22 +10540,36 @@ 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]]: - """`check`'s `(findings, remediation)` 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. Asked only of an `org:` - `execute` would get that far with: another is `unsafe-value`, refused - first in `_repository_urls`.""" + 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 [], [] + 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))) + list(dict.fromkeys(exc.remediation for exc in refused)), + [exc.code for exc in refused]) def _make_the_remote_dir(remote_dir: Path) -> None: diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 4e8da90e..2fea7260 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -5184,9 +5184,17 @@ def refuse_an_owner_github_cannot_spell(refusal: str, owner: str, named: str, 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 {owner!r}, and GitHub gives no user or organisation a " + 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_github_limits_init_check_org.py b/tests/test_github_limits_init_check_org.py index fe043f8d..ec19c068 100644 --- a/tests/test_github_limits_init_check_org.py +++ b/tests/test_github_limits_init_check_org.py @@ -83,6 +83,7 @@ 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` @@ -135,6 +136,28 @@ def test_every_character_and_every_hyphen_rule_broken_is_named_once(): "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") @@ -186,10 +209,12 @@ def refused_init(family, capsys, tmp_path: Path, *flags: str, 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(init_args(tmp_path, *flags)) + family.main(argv) err = capsys.readouterr().err - assert "too-long" not in err and "bad-org" not in err, 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)`. @@ -554,16 +579,18 @@ def stand_in(*args): 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(SimpleNamespace(local_remote_dir=None), - _Plan(), names_of(NORTHWIND), None) + 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."]) + ["Remediation: one.", "Remediation: two."], + ["first-code", "second-code"]) assert len(asked) == 2 @@ -659,9 +686,12 @@ def findings(stderr: str) -> list: 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, and - asks GitHub nothing: the leg question comes after, and `execute` never - gets that far. `execute` refuses it before a `gh` call or a leg.""" + 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) @@ -671,7 +701,9 @@ def test_check_finds_what_execute_refuses_under_its_code(tmp_path, 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 == [] + 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) == [] @@ -713,6 +745,138 @@ def test_check_passes_what_github_takes_and_asks_the_legs(tmp_path, 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): From 79d518dbeb3ae9baa2e78b48e38c20c08d53b42b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 05:00:08 +0000 Subject: [PATCH 13/13] tests: the two `check` runs of #842's `test_pin_source_top_level.py` take the `--local-remote-dir` that `check_plan` gives, as `test_adopt_pins.py`'s do, so the empty gh log still proves that `check` reads no network for a pin (#798) Main's #842 (bee84d55) added tests/test_pin_source_top_level.py after this branch's last merge. Its two `check` runs passed no `--local-remote-dir` and asserted `gh_calls(gh_env) == ""`; since this PR, `check` without that flag asks GitHub whether each leg repository exists, so the stub logged the two views and CI's `tests` failed 11 cases (7 of `test_check_finds_a_recorded_path_that_is_no_clone_s_top_level`, 4 of `test_check_asks_a_recorded_top_level_as_it_always_did`). Both now run `check_plan(plan, env=gh_env)`, imported from `test_adopt_pins`, as r798's re-verification gave it. No assertion changed. The file gives 48 passed. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_pin_source_top_level.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_pin_source_top_level.py b/tests/test_pin_source_top_level.py index 405e7fcb..5608d92a 100644 --- a/tests/test_pin_source_top_level.py +++ b/tests/test_pin_source_top_level.py @@ -36,8 +36,8 @@ from conftest import (ADOPT, ORG, SCAFFOLD, WINDOWS_SKIP, git, planned, refused_before_any_leg, run_script) from test_adopt_pins import ( # noqa: F401 - ENTRY, PIN_FILE, PLANNED, adopter, gh_calls, gh_env, read_from, - with_pins, + ENTRY, PIN_FILE, PLANNED, adopter, check_plan, gh_calls, gh_env, + read_from, with_pins, ) from test_pin_source_unreadable import RUNS, adoptable, made, run # noqa: F401 from repo_shape import Refusal, load_yaml, parse_yaml, tree_digest @@ -275,7 +275,7 @@ def test_check_finds_a_recorded_path_that_is_no_clone_s_top_level( plan = planned(source_repo, tmp_path, PLANNED) with_pins(plan, read_from(path, stand_ins.commit, recorded_on_main(path, stand_ins))) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 1, checked.stdout + checked.stderr lines = [line for line in checked.stderr.splitlines() if line.startswith("FINDING ")] @@ -309,7 +309,7 @@ def test_check_asks_a_recorded_top_level_as_it_always_did( path = stand_ins.base / where plan = planned(source_repo, tmp_path, PLANNED) with_pins(plan, read_from(path, stand_ins.commit, stand_ins.digest)) - checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + checked = check_plan(plan, env=gh_env) assert checked.returncode == 0, checked.stdout + checked.stderr said = (f"digest NOT rechecked here: the clone at {path} is a partial " "clone" if where == PARTIAL else