diff --git a/adopt-project.py b/adopt-project.py index 1e218f4b..07dd1cd2 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -171,7 +171,6 @@ import contextlib import datetime as _dt import itertools -import json import os import re import shlex @@ -185,7 +184,7 @@ sys.path.insert(0, str(SHAPE_ROOT / "scripts")) from path_classify import PathPolicy, Verdict # noqa: E402 from repo_shape import ( # noqa: E402 - free_plan_secret_hint, REMEDIATION, + REMEDIATION, COMMIT_RE, NEUTRAL_PRODUCT_OWNER, PROJECT_ID_RE, TREE_DIGEST_DEFINITION, VISIBILITY_CHOICES, NamingPolicy, Refusal, YamlError, accepts_role, checked_value, double_quoted, git_out, load_yaml, @@ -256,6 +255,12 @@ # `run`'s own check of a command line, for the one command `run` cannot run: # `_git_rm`, whose path goes to git as the bytes the tree holds (#527). from shape_materialize import check_program # noqa: E402 +# How a `gh repo view --json visibility` answer is read, and what the +# Free-plan secrets note says of the root, as the scaffold and `family.py` +# say it of theirs (#660). +from shape_materialize import ( # noqa: E402 + free_plan_secrets_note, visibility_in, +) #: 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. @@ -9203,19 +9208,6 @@ def _create_leg_remotes(plan: Plan, names: dict, repositories: dict, _free_plan_hint_by_the_root(plan, repositories["assembly"]) -#: What `execute` prints where GitHub would not say what visibility the -#: adopted root has (#602): the question it could not answer, what the -#: Free-plan hint would then say, and how to answer it. `{root}` is the root. -ROOT_VISIBILITY_UNREAD = ( - "NOTE this run could not read {root}'s visibility from GitHub, so it " - "cannot say whether the Free-plan secrets hint applies. It applies when " - "{root} is private or internal and its organisation is on the GitHub Free " - "plan: organisation secrets are then not delivered to it, so org-level " - "SHAPE_LEGS_APP_ID / SHAPE_LEGS_APP_PRIVATE_KEY resolve to EMPTY in its " - "`validate` workflow and must be set as REPOSITORY secrets on {root} " - "instead. `gh repo view {root} --json visibility` says which it is.") - - def _free_plan_hint_by_the_root(plan: Plan, root: str) -> None: """The Free-plan secrets hint, BY THE ADOPTED ROOT (#602). @@ -9235,21 +9227,23 @@ def _free_plan_hint_by_the_root(plan: Plan, root: str) -> None: A ROOT WHOSE VISIBILITY CANNOT BE READ IS NOT GUESSED. Read as private, a public root would be given a false hint; read as public, a private one would lose a true one with nothing said. `ROOT_VISIBILITY_UNREAD` says - so instead, with the condition and the command that settles it. It asks - nothing of the organisation's plan: `free_plan_secret_hint` answers None - alike for a paid plan and for one it could not read, so its answer could - not tell "the hint does not apply" from a second unknown. It is a NOTE - and not a refusal, because both legs exist by now and the run goes on to - push them. + so instead, with the condition and the command that settles it, and + asks nothing of the organisation's plan: the NOTE names both + conditions, so it holds whatever the plan is. It is a NOTE and not a + refusal, because both legs exist by now and the run goes on to push + them. + + NOR IS A PLAN THAT CANNOT BE READ (#660). `free_plan_secret_hint` + answered None alike for a paid plan and for one it could not read, so a + private root on an organisation whose plan the token could not see was + told nothing. `free_plan_secrets_note`, which the scaffold and + `family.py` ask too, and where `ROOT_VISIBILITY_UNREAD` now lives, says + `FREE_PLAN_UNREAD` there; the words adopt printed before are unchanged. """ - seen = _root_visibility(root) - if seen in ("private", "internal"): - hint = free_plan_secret_hint( - plan.get("org", ""), root, f"{root} is") - if hint: - print(hint) - elif seen is None: - print(ROOT_VISIBILITY_UNREAD.format(root=root)) + note = free_plan_secrets_note(plan.get("org", ""), root, + _root_visibility(root)) + if note: + print(note) def _root_visibility(repository: str) -> str | None: @@ -9257,19 +9251,16 @@ def _root_visibility(repository: str) -> str | None: None when it cannot be read: `gh repo view` failed (a repository the token cannot see, a rate limit, the network) or could not be started, or answered anything but one of GitHub's three visibilities. Never asked - under `--local-remote-dir`, whose root is the source on disk.""" + under `--local-remote-dir`, whose root is the source on disk. The answer + is read by `visibility_in`, the reading the scaffold's and `family.py`'s + `repository_visibility` gives theirs (#660); `gh` is run here, by this + module's `run`, so a `gh` that cannot be started is unread too.""" try: answer = run(["gh", "repo", "view", repository, "--json", "visibility"]) except (CommandFailed, OSError): return None - try: - value = json.loads(answer) - except ValueError: - return None - value = value.get("visibility") if isinstance(value, dict) else None - seen = value.lower() if isinstance(value, str) else None - return seen if seen in VISIBILITY_CHOICES else None + return visibility_in(answer) def _create_leg_remote(plan: Plan, names: dict, repositories: dict, diff --git a/scaffold-project.py b/scaffold-project.py index 51ead0ed..cf72e7af 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -84,7 +84,6 @@ SHAPE_ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(SHAPE_ROOT / "scripts")) from repo_shape import ( # noqa: E402 - free_plan_secret_hint, COMMIT_RE, NEUTRAL_PRODUCT_OWNER, PROJECT_ID_RE, TREE_DIGEST_DEFINITION, VISIBILITY_CHOICES, NamingPolicy, Refusal, accepts_role, checked_value, git_out, link_pins_from_trees, repo_basename, shell_line, tree_digest, @@ -103,6 +102,11 @@ ) # The leg-path rule `adopt-project.py` asks of its own paths (#179). from shape_materialize import refuse_bad_leg_paths # noqa: E402 +# The root's visibility and the Free-plan secrets note, asked as `family.py` +# asks them of its holder (#660). +from shape_materialize import ( # noqa: E402 + free_plan_secrets_note, repository_visibility, +) # What the new project's own reader and `validate-manifest.py` would make of # the values written into its manifest, and whether a repository's name can # be a directory's: asked before anything is created (#258, #261). @@ -223,8 +227,10 @@ def remote_is_empty(repository: str) -> bool | None: def remote_visibility(repository: str) -> str | None: """`/`'s GitHub visibility (`private`, `public`, `internal`), lowercased, or None if it cannot be read — the repository does not exist, - or `gh` cannot see it. Never called for a `--local-remote-dir` repository: - a bare repository on disk has no visibility at all. + `gh` cannot see it, or it answered a word that is not one of the three. + It is `repository_visibility`, the reader `family.py` asks of its holder + (#660). Never called for a `--local-remote-dir` repository: a bare + repository on disk has no visibility at all. NO `gh` ON PATH IS `gh-not-found` (#537), as `remote_is_empty`'s is, and not a `FileNotFoundError` traceback: `--reuse-empty-repo` reads the root's @@ -233,20 +239,10 @@ def remote_visibility(repository: str) -> str | None: has to come from here too or the run never reaches the one that has it. """ try: - proc = subprocess.run( - ["gh", "repo", "view", repository, "--json", "visibility"], - capture_output=True, text=True, encoding="utf-8", - errors="replace", check=False) + return repository_visibility(repository) except FileNotFoundError as exc: raise _gh_not_found( f"what visibility {repository} has on GitHub", exc) from exc - if proc.returncode != 0: - return None - try: - value = json.loads(proc.stdout).get("visibility") - except json.JSONDecodeError: - return None - return str(value).lower() if value else None def local_remote_is_empty(bare: Path) -> bool | None: @@ -1397,11 +1393,14 @@ def _create_remotes(args, values: dict, repositories: dict, urls: dict, reused one what GitHub says of it. A reused leg's is what `_reused_leg_visibility` read, with its WARNING. The reused ROOT's is read here without one, because `_leg_visibility` has already said where the - run's flag disagrees with it. + run's flag disagrees with it, and is None where GitHub does not say + (#660): the run's `--visibility` is not the root's, so a public root + whose read failed was given the Free-plan hint as private, and + `_private_leg_note` says now that it could not be read. """ display = values["PROJECT_NAME"] project_id = values["PROJECT_ID"] - standing: dict[str, str] = {} + standing: dict[str, str | None] = {} print("\ncreating remotes") for role in ("spec", "code", "assembly"): standing[role] = args.visibility @@ -1410,8 +1409,7 @@ def _create_remotes(args, values: dict, repositories: dict, urls: dict, + ("zero commits)" if reused[role] is None else f"holding this scaffold's seed {reused[role][:12]})")) if not local and role == "assembly": - standing[role] = (remote_visibility(repositories[role]) - or args.visibility) + standing[role] = remote_visibility(repositories[role]) elif not local: standing[role] = _reused_leg_visibility(args, repositories[role]) @@ -1480,6 +1478,13 @@ def _private_leg_note(args, repositories: dict, local: bool, was told the secrets resolve to EMPTY because a leg is private, when the reason is that the root is. `adopt-project.py` goes by its root the same way. + + WHAT GITHUB DOES NOT SAY IS SAID (#660), by `free_plan_secrets_note`, + which adopt and `family.py` ask too: a reused root whose visibility could + not be read (`standing` None) is `ROOT_VISIBILITY_UNREAD`, where it took + the run's `--visibility`, and a closed root whose organisation's plan + could not be read is `FREE_PLAN_UNREAD`, where it was silent as for a + paid plan. """ closed = [role for role in ("spec", "code") if standing[role] in ("private", "internal")] @@ -1497,12 +1502,10 @@ def _private_leg_note(args, repositories: dict, local: bool, "SHAPE_LEGS_APP_PRIVATE_KEY, preferred) or a SHAPE_LEGS_TOKEN " f"PAT (contents:read on {which}, fallback) — or the `validate` " f"check cannot check {them} out.") - root_is_closed = standing["assembly"] in ("private", "internal") - hint = root_is_closed and free_plan_secret_hint( - args.org, repositories["assembly"], - f"{repositories['assembly']} is") - if hint: - print(hint) + note = free_plan_secrets_note(args.org, repositories["assembly"], + standing["assembly"]) + if note: + print(note) def _take_held_seed(work: Path, url: str, seed: str, tracking: str) -> str: diff --git a/scripts/family.py b/scripts/family.py index 40d59442..8050172f 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -166,7 +166,6 @@ SHAPE_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(SHAPE_ROOT / "scripts")) from repo_shape import ( # noqa: E402 - free_plan_secret_hint, COMMIT_RE, PROJECT_ID_RE, PYTHON, TREE_DIGEST_DEFINITION, VISIBILITY_CHOICES, NamingPolicy, Refusal, YamlError, checked_value, double_quoted, git_out, load_yaml, parse_yaml, recorded_gitlink, @@ -195,6 +194,11 @@ # holder's clone URL under `--local-remote-dir` (#255, #433). It is the # question the scaffold and `adopt-project.py` ask of theirs. from shape_materialize import unwritable_reason # noqa: E402 +# A reused holder's own visibility and the Free-plan secrets note, asked as +# the scaffold asks them of its root (#660). +from shape_materialize import ( # noqa: E402 + free_plan_secrets_note, repository_visibility, +) NAMING_POLICY = SHAPE_ROOT / "contracts" / "repository-naming.yaml" MANIFEST = "family.yaml" @@ -1988,27 +1992,53 @@ def _create_holder_worktree(args, work: Path, values: dict, shape_commit: str, return None -def _finish_holder_remote(args, repository: str, topic: str, - local: bool) -> None: - """Set the holder's topic and print the private-visibility secrets hint. +def _finish_holder_remote(args, repository: str, topic: str, local: bool, + reuse: bool) -> None: + """Set the holder's topic and print the private-visibility NOTE and the + Free-plan secrets note. Neither applies to a local (no-network) remote. Split from `cmd_init` for #136. + + BY THE HOLDER AS IT STANDS (#660). The holder is the repository whose + `validate` workflow reads the secrets. One this run created has the + run's `--visibility`; one `--reuse-empty-repo` reused keeps its own, + which `--visibility` never touched, and GitHub is asked for it + (`repository_visibility`). Both lines went by `--visibility` alone, so + a public holder reused under the default `private` was called private + and given the Free-plan hint. A reused holder whose visibility GitHub + does not give is `ROOT_VISIBILITY_UNREAD` and gets no NOTE: whether it + is private is the very question that went unanswered. + + NO MEMBER IS NAMED THAT DOES NOT EXIST (#660). `init` mounts none, and + `add` mounts a member of any visibility, so the NOTE's "and its members + will be too" said what nothing here decides, and the hint's " + and its members are private" named members there are not. The hint + names the holder alone, as the scaffold's and adopt's name the root + (#602), and the NOTE says which members need the credential: those added + later that are private or internal. """ if local: return run(["gh", "repo", "edit", repository, "--add-topic", topic]) print(f" topic {topic} set on the holder") - if args.visibility in ("private", "internal"): - print(f"NOTE {repository} is {args.visibility} and its members will " - "be too: give it a way to read them — a GitHub App " + visibility = args.visibility + if reuse: + # `gh` has just set the topic, so an `OSError` here is no missing + # `gh` to refuse, only a visibility this run could not read. + try: + visibility = repository_visibility(repository) + except OSError: + visibility = None + if visibility in ("private", "internal"): + print(f"NOTE {repository} is {visibility}: give it a way to read the " + "members you add that are private or internal — a GitHub App " "(SHAPE_LEGS_APP_ID + SHAPE_LEGS_APP_PRIVATE_KEY, preferred) or " "a SHAPE_LEGS_TOKEN PAT with contents:read on the members AND " "their legs — or the `validate` check cannot check them out.") - hint = free_plan_secret_hint( - args.org, repository, f"{repository} and its members are") - if hint: - print(hint) + note = free_plan_secrets_note(args.org, repository, visibility) + if note: + print(note) def _print_next_steps(args, work: Path, folder: Path | None) -> None: @@ -2461,7 +2491,7 @@ def cmd_init(args) -> int: return code head = git_out(["rev-parse", "HEAD"], cwd=work)[:12] print(f" holder {head} -> {url}") - _finish_holder_remote(args, repository, topic, local) + _finish_holder_remote(args, repository, topic, local, reuse) _print_next_steps(args, work, folder) return 0 diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 8ebd2c51..053554eb 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -71,6 +71,9 @@ # How a `--pin-source` that is not printable is named, the one spelling the # scaffold's chain WARNING gives it too (#610). from repo_shape import shown_path # noqa: E402 +# 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 SHAPE_REPOSITORY = "opensoft/openRepoShape" @@ -1467,6 +1470,11 @@ def target_remediation(why: str, holds: str, choose: str) -> str: #: `FetchRepository` says the same when the API returns no repository). GH_NOT_FOUND_WORDS = "Could not resolve to a Repository" +#: The same answer from `gh api`, which asks GitHub's REST API: gh prints a +#: REST error as `gh: (HTTP )` on stderr, with exit 1, and +#: GitHub's message for a 404 is `Not Found` (#660). +GH_API_NOT_FOUND_WORDS = "Not Found (HTTP 404)" + #: Said by every refusal of a `gh repo view` that failed otherwise. GH_UNREADABLE_REMEDIATION = ( "Remediation: nothing was created, and `--dry-run` refuses the same. " @@ -1477,6 +1485,19 @@ def target_remediation(why: str, holds: str, choose: str) -> str: "expired), or let a rate limit or an outage pass, then run this again.") +def gh_said_not_found(probe, words: str = GH_NOT_FOUND_WORDS) -> bool: + """Is `probe`, a finished `gh` command, gh's own answer that what it asked + for does not exist: exit 1 with gh's not-found `words` on stderr (#609)? + + The ONE reading of that answer, `refuse_unless_not_found`'s, with the + words for each API gh asks: `GH_NOT_FOUND_WORDS` for `gh repo view`, and + `GH_API_NOT_FOUND_WORDS` for `gh api`, which `organisation_plan` asks + (#660). Every other failure, a bad login, a rate limit or an outage, is + not this answer, whatever else it says. + """ + return probe.returncode == 1 and words in (probe.stderr or "") + + def refuse_unless_not_found(repository: str, probe) -> None: """A `gh repo view ` that FAILED is gh's answer that the repository does not exist, and this returns; any other failure is @@ -1495,7 +1516,7 @@ def refuse_unless_not_found(repository: str, probe) -> None: `probe` is the finished `subprocess.CompletedProcess` of the command; one that exited 0 is not asked about here. """ - if probe.returncode == 1 and GH_NOT_FOUND_WORDS in (probe.stderr or ""): + if gh_said_not_found(probe): return lines = [line.strip() for line in (probe.stderr or probe.stdout or "").splitlines() @@ -1512,6 +1533,165 @@ def refuse_unless_not_found(repository: str, probe) -> None: GH_UNREADABLE_REMEDIATION) +# --------------------------------------------------------------------------- +# What the Free-plan secrets hint goes by (#602, #660) +# --------------------------------------------------------------------------- +# +# The `validate` workflow that reads SHAPE_LEGS_APP_ID and +# SHAPE_LEGS_APP_PRIVATE_KEY runs in ONE repository, the assembly root or the +# family holder, and on the GitHub Free plan an organisation secret is +# delivered only to a public repository. So `FREE_PLAN_HINT` holds when that +# repository is private or internal AND its organisation is on Free: two +# questions for GitHub, and either can go unanswered. AN UNANSWERED ONE IS +# SAID, NEVER GUESSED. Guessing private gives a public repository a false +# hint; guessing public, or a paid plan, hides a true one with nothing said. +# `free_plan_secret_hint` in `repo_shape.py` answers None alike for a paid +# plan and for one it could not read, as its docstring chooses, and that file +# is a shape copy every project digest-pins; so the three creating tools ask +# the plan here instead, and `FREE_PLAN_HINT`'s words are not changed. + +#: What a creating tool prints where GitHub would not say what visibility the +#: repository its `validate` workflow runs in has (#602, from +#: `adopt-project.py`, whose words these are; the scaffold's reused root and +#: `family.py init`'s reused holder since #660): the question it could not +#: answer, what the Free-plan hint would then say, and how to answer it. +#: `{root}` is that repository. +ROOT_VISIBILITY_UNREAD = ( + "NOTE this run could not read {root}'s visibility from GitHub, so it " + "cannot say whether the Free-plan secrets hint applies. It applies when " + "{root} is private or internal and its organisation is on the GitHub Free " + "plan: organisation secrets are then not delivered to it, so org-level " + "SHAPE_LEGS_APP_ID / SHAPE_LEGS_APP_PRIVATE_KEY resolve to EMPTY in its " + "`validate` workflow and must be set as REPOSITORY secrets on {root} " + "instead. `gh repo view {root} --json visibility` says which it is.") + +#: The same where the repository is private or internal and GitHub would not +#: name its organisation's plan (#660). `{visibility}` is the repository's. +#: The last sentence rests on GitHub's REST page for `GET /orgs/{org}`: an +#: organisation's full details are shown to its owners. +FREE_PLAN_UNREAD = ( + "NOTE this run could not read {org}'s plan from GitHub, so it cannot say " + "whether the Free-plan secrets hint applies. {root} is {visibility}, so " + "it applies when {org} is on the GitHub Free plan: organisation secrets " + "are then not delivered to {root}, so org-level SHAPE_LEGS_APP_ID / " + "SHAPE_LEGS_APP_PRIVATE_KEY resolve to EMPTY in its `validate` workflow " + "and must be set as REPOSITORY secrets on {root} instead. `gh api " + "orgs/{org} --jq .plan.name`, run with an owner's login, says which plan " + "it is on.") + + +def visibility_in(answer: str) -> str | None: + """The visibility a `gh repo view --json visibility` answer names, one of + `VISIBILITY_CHOICES` in any case, or None for anything else: not JSON, + not an object, no field, or a word that is not one of GitHub's three. + `repository_visibility` reads its answer with it, and `adopt-project.py`'s + `_root_visibility` reads its own (#660).""" + try: + value = json.loads(answer) + except ValueError: + return None + value = value.get("visibility") if isinstance(value, dict) else None + seen = value.lower() if isinstance(value, str) else None + return seen if seen in VISIBILITY_CHOICES else None + + +def repository_visibility(repository: str) -> str | None: + """`repository`'s visibility on GitHub, one of `VISIBILITY_CHOICES`, or + None when it cannot be read: `gh repo view` failed (a repository the + token cannot see, a rate limit, an outage such as an `HTTP 502`) or + answered anything `visibility_in` does not read as one of the three. + + THE ONE READER of the scaffold and `family.py` (#660), on the model of + `adopt-project.py`'s `_root_visibility` (#602). A `gh` that cannot be + started is NOT answered here: its `OSError` reaches the caller, because + the scaffold refuses a missing `gh` as `gh-not-found` before it creates + anything (#537), and `family.py`, which asks only after `gh` has made or + edited the holder, takes it as unread. Never asked of a + `--local-remote-dir` repository, which has no visibility. + """ + try: + answer = run(["gh", "repo", "view", repository, "--json", + "visibility"]) + except CommandFailed: + return None + return visibility_in(answer) + + +def organisation_plan(org: str) -> str | None: + """`"free"` when GitHub names `org`'s plan `free` (in any case, around any + whitespace), `"other"` when it names another, `"not-an-organisation"` + when gh says there is no organisation `org`, and None when GitHub names + no plan (#660): `gh api orgs/` failed otherwise (an organisation + the token cannot see, a rate limit, an outage) or could not be started, + or answered with no plan name, which is what `--jq .plan.name` prints + where the field is absent from what the token was shown. + + NO ORGANISATION IS NOT AN UNREAD PLAN. GitHub answers `GET /orgs/` + with 404 where `` is not an organisation, which is what a + repository owned by a personal account gives, and a personal account + has no organisation secrets for the hint to be about. gh's not-found + answer is read by `gh_said_not_found`, the reading + `refuse_unless_not_found` gives `gh repo view` (#609), so every other + failure stays unread. The command is run here rather than by `run`, + because that reading is of the finished command's own stderr, which + `run` folds into one text with stdout. + + `free_plan_secret_hint` reads the same field and answers None for every + case but `free`, as for a paid plan, so a private root whose plan could + not be read was told nothing, the silent failure its own docstring says + it exists for. Each creating tool asks this instead, through + `free_plan_secrets_note`, and names an unread plan. + """ + try: + probe = subprocess.run( + ["gh", "api", f"orgs/{org}", "--jq", ".plan.name"], + capture_output=True, text=True, encoding="utf-8", + errors="replace", check=False) + except OSError: + return None + if gh_said_not_found(probe, GH_API_NOT_FOUND_WORDS): + return "not-an-organisation" + if probe.returncode != 0: + return None + name = (probe.stdout or "").strip().lower() + if not name: + return None + return "free" if name == "free" else "other" + + +def free_plan_secrets_note(org: str, root: str, + visibility: str | None) -> str | None: + """What a creating tool prints about the Free-plan secrets of `root`, the + repository its `validate` workflow runs in, at `visibility` (None: GitHub + did not say), or None where nothing applies (#602, #660). + + - Unread: `ROOT_VISIBILITY_UNREAD`, and the plan is not asked: that NOTE + names both conditions, so it holds whatever the plan is. + - Public: None. An organisation secret reaches it on every plan. + - Private or internal: the plan is asked. On `free` it is + `FREE_PLAN_HINT`, with `root` alone as the repository that is private, + as the scaffold and adopt name it since #602 and `family.py init` since + #660; where the plan is unread, `FREE_PLAN_UNREAD`; on any other + plan, and where the owner is not an organisation, None. + + The scaffold passes the run's visibility for a root it created and + GitHub's for one it reused, adopt GitHub's for the root it adopted, and + `family.py` the run's for a holder it created and GitHub's for one it + reused. + """ + if visibility is None: + return ROOT_VISIBILITY_UNREAD.format(root=root) + if visibility not in ("private", "internal"): + return None + plan = organisation_plan(org) + if plan == "free": + return FREE_PLAN_HINT.format(org=org, repo=root, what=f"{root} is") + if plan is None: + return FREE_PLAN_UNREAD.format(org=org, root=root, + visibility=visibility) + return None + + #: `-F -` reads the message from STDIN. Every argument is then a literal, and #: the one value a caller controls — the message, which carries a project's #: display name and, in an adoption, every moved path out of a plan file — diff --git a/tests/test_adopt_free_plan_hint_root.py b/tests/test_adopt_free_plan_hint_root.py index 24b5009b..3c55bee1 100644 --- a/tests/test_adopt_free_plan_hint_root.py +++ b/tests/test_adopt_free_plan_hint_root.py @@ -80,13 +80,16 @@ def adopter(): def create_the_legs(adopter, monkeypatch, capsys, tmp_path, repos: dict, *, plan: str | None = "free", visibility: str = "private", local: bool = False, - view_errors: dict | None = None) -> tuple[str, list]: + view_errors: dict | None = None, + **stub) -> tuple[str, list]: """`_create_leg_remotes` for `NAMES` under `ORG`, as `cmd_execute` calls it, with the stub `gh` knowing `repos` (`{name: VISIBILITY}`), naming `plan` as the organisation's plan and failing `repo view` of each name in - `view_errors` (`{name: stderr text}`). Returns what it printed and the - `gh` calls it made, each as its arguments joined by spaces.""" - env, log = fake_gh(tmp_path, repos, plan=plan, view_errors=view_errors) + `view_errors` (`{name: stderr text}`), and taking `fake_gh`'s other + keywords as `stub`. Returns what it printed and the `gh` calls it made, + each as its arguments joined by spaces.""" + env, log = fake_gh(tmp_path, repos, plan=plan, view_errors=view_errors, + **stub) for key, value in env.items(): monkeypatch.setenv(key, value) if local: diff --git a/tests/test_free_plan_hint.py b/tests/test_free_plan_hint.py index 22fd1b32..05633d0a 100644 --- a/tests/test_free_plan_hint.py +++ b/tests/test_free_plan_hint.py @@ -9,10 +9,19 @@ the App was installed on every repository and the org secrets existed at `visibility: all`, and both split pull requests still fetched no legs. -So the hint has to be right in BOTH directions, and the tests below pin both: -it appears on `free`, and it stays silent on every other answer — a paid plan, -no `gh` on PATH, a failed call, an unparseable one. A wrong hint about -credentials is worse than none. +So the hint has to be right in BOTH directions. The tests below pin both of +`free_plan_secret_hint`, the pinned copy's own reader in `repo_shape.py`: it +appears on `free`, and it stays silent on every other answer — a paid plan, +no `gh` on PATH, a failed call, an unparseable one — because a wrong hint +about credentials is worse than none. + +NO CREATING TOOL CALLS IT SINCE #660. Each asks +`shape_materialize.free_plan_secrets_note`, which gives the same hint on +`free`, says nothing on a paid plan or for an owner that is not an +organisation, and NAMES a plan it could not read, where this one is silent; +`tests/test_free_plan_hint_reads.py` pins that. Here the last two tests pin +that every tool asks it with its own organisation, and that the documents +say the hint too. """ from __future__ import annotations @@ -116,10 +125,12 @@ def test_no_gh_on_path_is_silent(tmp_path, monkeypatch): ]) def test_every_creating_tool_calls_the_hint(tool, call): """All three creators, or the next org on Free hits it in the one place - that was missed.""" + that was missed. Each asks `shape_materialize.free_plan_secrets_note` + since #660, which gives `FREE_PLAN_HINT` on a `free` plan and names a + plan it could not read, where `free_plan_secret_hint` was silent.""" source = (REPO / tool).read_text() - assert "free_plan_secret_hint" in source, f"{tool} does not import it" - assert f"free_plan_secret_hint(\n {call}" in source, \ + assert "free_plan_secrets_note" in source, f"{tool} does not import it" + assert f"free_plan_secrets_note({call}," in source, \ f"{tool} does not call it with its own org" diff --git a/tests/test_free_plan_hint_reads.py b/tests/test_free_plan_hint_reads.py new file mode 100644 index 00000000..9ef08847 --- /dev/null +++ b/tests/test_free_plan_hint_reads.py @@ -0,0 +1,379 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The Free-plan secrets hint's two reads, in all three creating tools +(#660). + +The hint (`FREE_PLAN_HINT`) holds when the repository whose `validate` +workflow reads `SHAPE_LEGS_APP_ID` and `SHAPE_LEGS_APP_PRIVATE_KEY` -- the +assembly root, or the family holder -- is private or internal AND its +organisation is on the GitHub Free plan, where an organisation secret is +delivered only to a public repository. Two questions for GitHub, then, and +since #602 adopt and the scaffold ask the first of the root. + +WHAT WAS WRONG. +- `family.py init --reuse-empty-repo` went by `--visibility`, whose default is + `private`, and never asked the reused holder its own (L2-58): a PUBLIC + holder was told it "is private" and given the hint. +- The scaffold's reused root took the run's `--visibility` where GitHub did + not say what it is (L2-57), so a public root whose read failed was given + the hint as private. +- `free_plan_secret_hint` answers None alike for a paid plan and for one it + could not read (L2-59), so a private root whose plan the token could not + see was told nothing: the silent failure the hint exists for. +- `init`'s hint said " and its members are private" (L2-71), and its + NOTE "and its members will be too", of members `init` never makes. + +WHAT IS PINNED. One table, the three tools: a root or holder GitHub calls +PUBLIC (no hint, no plan asked), PRIVATE (the hint, naming it alone), one +whose visibility read fails with `HTTP 502` (said to be unread, never +guessed), and a private one whose plan read fails or names no plan (said to +be unread, never taken as paid). Then the readers in `shape_materialize.py` +on their own. + +`gh` is `test_scaffold_reuse_seeded_legs.py`'s stub, first on PATH: it knows +each repository at its visibility, names the plan or fails it, and fails the +one `--json visibility` read it is told to. Nothing reaches GitHub. It is a +`#!` script, so the runs that start it are `WINDOWS_SKIP`ped; the readers' +own tests below run everywhere. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys + +import pytest + +import test_adopt_free_plan_hint_root as adopt_hint +from conftest import (ORG, PROJECT, REPO, WINDOWS_SKIP, load_without_bytecode, + run_script) +from test_adopt_free_plan_hint_root import NORTHWIND, create_the_legs +from test_scaffold_reuse_seeded_legs import fake_gh, on_github + +sys.path.insert(0, str(REPO / "scripts")) +import shape_materialize # noqa: E402 +from repo_shape import load_yaml # noqa: E402 + +#: `adopt-project.py` loaded once for this module, by the fixture of the file +#: whose `create_the_legs` the adopt rows run. +adopter = adopt_hint.adopter +FAMILY = REPO / "scripts" / "family.py" +HOLDER = "Orbit" +#: The repository each tool's `validate` workflow runs in, by tool. +ROOTS = {"scaffold": f"{ORG}/{PROJECT}", "adopt": f"{ORG}/{NORTHWIND}", + "family": f"{ORG}/{HOLDER}"} +#: What `gh` says on stderr, with exit 1: an outage; a token an organisation's +#: SAML enforcement refuses (a 403, in GraphQL's words); a repository GitHub +#: does not show it; and the REST 404 GitHub gives `orgs/` for a login +#: that is not an organisation, such as a personal account (#660's F1). +BAD_GATEWAY = "HTTP 502: Bad Gateway (https://api.github.com/graphql)" +SAML_REFUSED = ("GraphQL: Resource protected by organization SAML enforcement." + " You must grant your Personal Access token access to this " + "organization. (repository)") +NOT_FOUND = ("GraphQL: Could not resolve to a Repository with the name " + f"'{ORG}/Orbit'. (repository)") +NOT_AN_ORGANISATION = "gh: Not Found (HTTP 404)" +PLAN_ASKED = f"api orgs/{ORG} --jq .plan.name" +HINT = f"NOTE {ORG} is on the GitHub FREE plan" + + +def unread_root(root: str) -> list[str]: + """What a run says of a root whose visibility it could not read.""" + return [f"NOTE this run could not read {root}'s visibility from GitHub, " + "so it cannot say whether the Free-plan secrets hint applies.", + f"`gh repo view {root} --json visibility` says which it is."] + + +def unread_plan(root: str) -> list[str]: + """What a run says of a private root whose organisation's plan it could + not read.""" + return [f"NOTE this run could not read {ORG}'s plan from GitHub, so it " + "cannot say whether the Free-plan secrets hint applies. " + f"{root} is private, so it applies when {ORG} is on the GitHub " + "Free plan: organisation secrets are then not delivered to " + f"{root}, so org-level SHAPE_LEGS_APP_ID / " + "SHAPE_LEGS_APP_PRIVATE_KEY resolve to EMPTY in its `validate` " + f"workflow and must be set as REPOSITORY secrets on {root} " + f"instead. `gh api orgs/{ORG} --jq .plan.name`, run with an " + "owner's login, says which plan it is on."] + + +def init_on_github(tmp_path, env: dict, *extra: str): + """`family.py init` of `HOLDER` in its GitHub mode, against the stub, + pushing nothing.""" + return run_script(FAMILY, "init", "--org", ORG, "--family", HOLDER, + "--created-by", "Test Human", "--no-push", + "--work-dir", str(tmp_path / "work"), *extra, env=env) + + +def run_tool(tool: str, tmp_path, monkeypatch, capsys, module, + seen: str, **stub) -> tuple[str, list]: + """`tool`, against the stub, over a root (or holder) GitHub knows at + `seen`, with closed legs where it has legs: what it printed, and its + `gh` calls as their arguments joined by spaces. + + The scaffold and `init` reuse an empty root or holder (`--reuse-empty- + repo`), the one case where the run's `--visibility` is not that + repository's own; adopt's root is the source it adopts, and + `_create_leg_remotes` is run as `cmd_execute` runs it, in `module`, + `adopt-project.py` loaded.""" + if tool == "adopt": + return create_the_legs(module, monkeypatch, capsys, tmp_path, + {NORTHWIND: seen}, **stub) + if tool == "scaffold": + env, log = fake_gh(tmp_path, {PROJECT: seen, + f"{PROJECT}-spec": "PRIVATE", + f"{PROJECT}-code": "PRIVATE"}, **stub) + result = on_github(tmp_path, "--reuse-empty-repo", env=env) + else: + env, log = fake_gh(tmp_path, {HOLDER: seen}, **stub) + result = init_on_github(tmp_path, env, "--reuse-empty-repo") + assert result.returncode == 0, result.stderr + result.stdout + return (result.stdout + result.stderr, + log.read_text(encoding="utf-8").splitlines()) + + +#: `(root's visibility, stub keywords, what is said)`: "hint" is the Free-plan +#: hint naming the root, "root" the unread-visibility NOTE, "plan" the +#: unread-plan NOTE, and None nothing about the secrets at all. A +#: `visibility_errors` text fails the root's `--json visibility` read alone. +#: An owner GitHub says is no organisation has no organisation secrets, so +#: nothing is said of them: it is not a plan this run could not read. +CASES = { + "public": ("PUBLIC", {"plan": "free"}, None), + "private": ("PRIVATE", {"plan": "free"}, "hint"), + "visibility-502": ("PRIVATE", {"plan": "free", + "visibility_errors": BAD_GATEWAY}, "root"), + "visibility-403": ("PRIVATE", {"plan": "free", + "visibility_errors": SAML_REFUSED}, "root"), + "plan-502": ("PRIVATE", {"plan_error": BAD_GATEWAY}, "plan"), + "not-an-organisation": ("PRIVATE", {"plan_error": NOT_AN_ORGANISATION}, + None), + "plan-unnamed": ("PRIVATE", {"plan": ""}, "plan"), + "paid": ("PRIVATE", {"plan": "team"}, None), +} + + +@WINDOWS_SKIP +@pytest.mark.parametrize("case", list(CASES)) +@pytest.mark.parametrize("tool", list(ROOTS)) +def test_each_tool_says_what_github_said_of_the_root_and_its_plan( + tool, case, tmp_path, monkeypatch, capsys, adopter): + """L2-57, L2-58, L2-59 and L2-71, in each tool. The hint is printed only + for a root GitHub calls private or internal on a plan it calls `free`, + with the root alone as the repository that is private; a root whose + visibility GitHub does not give is said to be unread and the plan is not + asked; a closed root whose plan GitHub does not name is said to be on a + plan this run could not read. On main `init` called a public holder + private and gave it the hint, the scaffold took the run's visibility for + a root it could not read, and every tool was silent over an unread plan, + as over a paid one.""" + seen, stub, said = CASES[case] + root = ROOTS[tool] + if "visibility_errors" in stub: + stub = {**stub, "visibility_errors": { + root.split("/", 1)[1]: stub["visibility_errors"]}} + printed, calls = run_tool(tool, tmp_path, monkeypatch, capsys, adopter, + seen, **stub) + assert f"repo view {root} --json visibility" in calls, calls + lines = {"hint": [HINT, f"{root} is private, so org-level", + f"--repo {root} --body"], + "root": unread_root(root), "plan": unread_plan(root)} + for kind, expected in lines.items(): + for line in expected: + assert (line in printed) == (kind == said), (kind, line, printed) + assert (PLAN_ASKED in calls) == (seen == "PRIVATE" + and said != "root"), calls + assert "its members" not in printed, printed + + +#: The NOTE `init` prints for a closed holder, after its subject. +HOLDER_NOTE = (": give it a way to read the members you add that are private " + "or internal — a GitHub App (SHAPE_LEGS_APP_ID + " + "SHAPE_LEGS_APP_PRIVATE_KEY, preferred) or a SHAPE_LEGS_TOKEN " + "PAT with contents:read on the members AND their legs — or the " + "`validate` check cannot check them out.") + + +@WINDOWS_SKIP +@pytest.mark.parametrize("seen, errors, noted", [ + ("PUBLIC", None, None), + ("PRIVATE", None, "private"), + ("INTERNAL", None, "internal"), + ("PRIVATE", {HOLDER: BAD_GATEWAY}, None), + ("PRIVATE", {HOLDER: NOT_FOUND}, None), +], ids=["public", "private", "internal", "visibility-502", "visibility-404"]) +def test_init_notes_a_reused_holder_at_its_own_visibility( + tmp_path, monkeypatch, capsys, seen, errors, noted): + """L2-58 and the NOTE's half of L2-71. `init`'s NOTE goes by the reused + holder as GitHub reports it, and names no member: `init` mounts none, and + `add` mounts a member of any visibility, so "and its members will be + too" said what nothing decides. An unread holder gets no NOTE, since + whether it is private is the question that went unanswered. On main the + NOTE said "is private and its members will be too" of every holder the + default `--visibility private` reused, the public one included. + + family.yaml keeps the RUN's `--visibility`, as it always has, which is + the decision #660 records: the table that writes it is checked before + the plan, before the holder is known to exist.""" + printed, _calls = run_tool("family", tmp_path, monkeypatch, capsys, None, + seen, plan="team", visibility_errors=errors) + holder = ROOTS["family"] + if noted is None: + assert f"NOTE {holder} is " not in printed, printed + else: + assert f"NOTE {holder} is {noted}{HOLDER_NOTE}" in printed, printed + assert "will be too" not in printed, printed + manifest = load_yaml(tmp_path / "work" / HOLDER / "family.yaml") + assert manifest["visibility"] == "private" + + +@WINDOWS_SKIP +def test_init_asks_nothing_of_a_holder_it_creates(tmp_path): + """The control: a holder this run creates is created at the run's + `--visibility`, which is what it is, so GitHub is not asked; it gets the + NOTE and, on Free, the hint naming it alone.""" + env, log = fake_gh(tmp_path, {}, plan="free") + result = init_on_github(tmp_path, env) + assert result.returncode == 0, result.stderr + result.stdout + holder = ROOTS["family"] + assert f"NOTE {holder} is private{HOLDER_NOTE}" in result.stdout + assert f"{holder} is private, so org-level" in result.stdout + calls = log.read_text(encoding="utf-8").splitlines() + assert f"repo create {holder} --private" in " | ".join(calls), calls + assert f"repo view {holder} --json visibility" not in calls, calls + + +def test_init_takes_a_holder_gh_cannot_be_asked_about_as_unread( + monkeypatch, capsys): + """A reused holder whose visibility read cannot start `gh` (an `OSError`) + is unread, as a failed read is: `ROOT_VISIBILITY_UNREAD`, and no NOTE + and no hint at the run's `--visibility`, which is not the holder's + (#660's F8). In process, so it runs on every platform: the topic edit and + the plan are answered here, and nothing reaches `gh`.""" + family = load_without_bytecode(FAMILY, "family_free_plan_hint_reads") + edits: list = [] + monkeypatch.setattr(family, "run", + lambda args, *_rest, **_keywords: edits.append(args)) + + def cannot_start(repository): + raise FileNotFoundError(2, "No such file or directory", "gh") + + monkeypatch.setattr(family, "repository_visibility", cannot_start) + monkeypatch.setattr(shape_materialize, "organisation_plan", + lambda org: "free") + holder = ROOTS["family"] + family._finish_holder_remote( + argparse.Namespace(org=ORG, visibility="private"), holder, + "xf-project-orbit", False, True) + printed = capsys.readouterr().out + assert unread_root(holder)[0] in printed, printed + assert f"NOTE {holder} is " not in printed, printed + assert HINT not in printed, printed + assert edits == [["gh", "repo", "edit", holder, "--add-topic", + "xf-project-orbit"]] + + +# --- the readers, on every platform ----------------------------------------- + +def answering(monkeypatch, answer) -> list: + """`shape_materialize.run` answering every command with `answer`, or + raising it when it is an exception; returns the commands it was asked.""" + asked: list = [] + + def run(args, *_rest, **_keywords): + asked.append(args) + if isinstance(answer, BaseException): + raise answer + return answer + + monkeypatch.setattr(shape_materialize, "run", run) + return asked + + +FAILED = shape_materialize.CommandFailed(["gh"], None, 1, BAD_GATEWAY) +NO_GH = FileNotFoundError(2, "No such file or directory", "gh") + + +#: The JSON body `gh api` prints on stdout for a REST 404. +NOT_FOUND_BODY = ('{"message":"Not Found","documentation_url":"https://docs.' + 'github.com/rest/orgs/orgs#get-an-organization",' + '"status":"404"}') + + +@pytest.mark.parametrize("answer, plan", [ + ((0, "free\n", ""), "free"), ((0, " Free \n", ""), "free"), + ((0, "team\n", ""), "other"), ((0, "enterprise\n", ""), "other"), + ((0, "", ""), None), ((0, "\n", ""), None), + ((1, NOT_FOUND_BODY, NOT_AN_ORGANISATION + "\n"), "not-an-organisation"), + ((1, "", "gh: Resource protected by organization SAML enforcement. (HTTP " + "403)\n"), None), + ((1, "", f"gh: {BAD_GATEWAY}\n"), None), + ((1, NOT_FOUND_BODY, f"gh: {BAD_GATEWAY}\n"), None), + (NO_GH, None), +], ids=["free", "free-spaced", "team", "enterprise", "unnamed", + "blank-line", "not-an-organisation", "saml-403", "failed-502", + "not-found-on-stdout-alone", "no-gh"]) +def test_the_plan_is_free_other_or_unread(monkeypatch, answer, plan): + """`organisation_plan`: GitHub's word for the plan, `free` or any other; + `not-an-organisation` where gh says, on stderr, that there is no such + organisation (the reading `refuse_unless_not_found` gives, #609); and + None where it names no plan, never "other" for a plan it did not name. + The command is asked of `subprocess.run`, faked here, since the reading + is of stderr alone.""" + asked: list = [] + + def gh(args, *_rest, **_keywords): + asked.append(list(args)) + if isinstance(answer, BaseException): + raise answer + return subprocess.CompletedProcess(args, *answer) + + monkeypatch.setattr(shape_materialize.subprocess, "run", gh) + assert shape_materialize.organisation_plan(ORG) == plan + assert asked == [["gh", "api", f"orgs/{ORG}", "--jq", ".plan.name"]] + + +def test_a_visibility_read_is_one_of_three_or_unread(monkeypatch): + """`repository_visibility`: what `visibility_in` reads, None for a failed + `gh repo view`, and a `gh` that cannot be started left to the caller, + which the scaffold refuses as `gh-not-found` (#537).""" + root = ROOTS["scaffold"] + asked = answering(monkeypatch, '{"visibility": "Internal"}') + assert shape_materialize.repository_visibility(root) == "internal" + assert asked == [["gh", "repo", "view", root, "--json", "visibility"]] + answering(monkeypatch, '{"visibility": "SECRET"}') + assert shape_materialize.repository_visibility(root) is None + answering(monkeypatch, FAILED) + assert shape_materialize.repository_visibility(root) is None + answering(monkeypatch, NO_GH) + with pytest.raises(FileNotFoundError): + shape_materialize.repository_visibility(root) + + +@pytest.mark.parametrize("visibility, plan, said", [ + (None, "free", "root"), ("public", "free", None), + ("private", "free", "hint"), ("internal", "free", "hint"), + ("private", "other", None), ("private", None, "plan"), + ("private", "not-an-organisation", None), +], ids=["unread-root", "public", "private-free", "internal-free", + "private-paid", "private-unread-plan", "private-not-an-organisation"]) +def test_the_note_goes_by_both_answers(monkeypatch, visibility, plan, said): + """`free_plan_secrets_note`, the one decision the three tools ask: the + plan is asked only of a closed root, and what neither GitHub answer + gives is said.""" + asked: list = [] + monkeypatch.setattr(shape_materialize, "organisation_plan", + lambda org: asked.append(org) or plan) + root = ROOTS["adopt"] + note = shape_materialize.free_plan_secrets_note(ORG, root, visibility) + first = {"hint": HINT, "root": unread_root(root)[0], + "plan": f"NOTE this run could not read {ORG}'s plan"} + if said is None: + assert note is None + else: + assert note.startswith(first[said]), note + if said == "hint": + assert f"{root} is private, so org-level" in note, note + assert asked == ([ORG] if visibility in ("private", "internal") + else []), asked diff --git a/tests/test_scaffold_reuse_seeded_legs.py b/tests/test_scaffold_reuse_seeded_legs.py index 7d401efe..d32eb7d0 100644 --- a/tests/test_scaffold_reuse_seeded_legs.py +++ b/tests/test_scaffold_reuse_seeded_legs.py @@ -309,10 +309,13 @@ def test_a_leg_somebody_else_made_is_refused(tmp_path): #: `FAKE_GH_FULL` names it too (`org/name,...`), when its commit listing has #: one commit; it answers `repo create` and `repo edit` with success, answers #: `api orgs/` with `FAKE_GH_PLAN` when that is set (the Free-plan hint -#: asks it for `.plan.name`), answers `repo view` of each repository that +#: asks it for `.plan.name`), and with `FAKE_GH_PLAN_ERROR` on stderr and +#: exit 1 when that is, answers `repo view` of each repository that #: `FAKE_GH_VIEW_ERRORS` (JSON, `{"org/name": "stderr text"}`) names with that #: text and exit 1, known or not (a 401, where an unknown one is not-found), -#: and logs every call. +#: answers only `repo view --json visibility` of each that +#: `FAKE_GH_VISIBILITY_ERRORS` names so (an `HTTP 502` after `--json name` +#: found it), and logs every call. FAKE_GH = textwrap.dedent("""\ #!/usr/bin/env python3 import json @@ -326,22 +329,30 @@ def test_a_leg_somebody_else_made_is_refused(tmp_path): os.environ["FAKE_GH_REPOS"].split(",") if pair) FULL = os.environ.get("FAKE_GH_FULL", "").split(",") ERRORS = json.loads(os.environ.get("FAKE_GH_VIEW_ERRORS", "{}")) + UNSEEN = json.loads(os.environ.get("FAKE_GH_VISIBILITY_ERRORS", "{}")) if argv[:2] == ["repo", "view"]: repo = argv[2] + fields = (argv[argv.index("--json") + 1].split(",") + if "--json" in argv else []) + if repo in UNSEEN and "visibility" in fields: + ERRORS[repo] = UNSEEN[repo] if repo in ERRORS: print(ERRORS[repo], file=sys.stderr) sys.exit(1) if repo not in KNOWN: print("GraphQL: Could not resolve to a Repository", file=sys.stderr) sys.exit(1) - fields = argv[argv.index("--json") + 1].split(",") out = {} if "name" in fields: out["name"] = repo.rsplit("/", 1)[-1] if "visibility" in fields: out["visibility"] = KNOWN[repo] print(json.dumps(out)) + elif (argv[:1] == ["api"] and argv[1].startswith("orgs/") + and "FAKE_GH_PLAN_ERROR" in os.environ): + print(os.environ["FAKE_GH_PLAN_ERROR"], file=sys.stderr) + sys.exit(1) elif (argv[:1] == ["api"] and argv[1].startswith("orgs/") and "FAKE_GH_PLAN" in os.environ): print(os.environ["FAKE_GH_PLAN"]) @@ -357,13 +368,17 @@ def test_a_leg_somebody_else_made_is_refused(tmp_path): def fake_gh(tmp_path, repos: dict, full=(), plan: str | None = None, - view_errors: dict | None = None) -> tuple[dict, Path]: + view_errors: dict | None = None, *, + visibility_errors: dict | None = None, + plan_error: str | None = None) -> tuple[dict, Path]: """`FAKE_GH` first on PATH, knowing `repos` (`{name: VISIBILITY}`, each under `ORG`) and holding commits in each of `full`, naming `plan` as the - organisation's GitHub plan when one is given, and failing `repo view` of - each name in `view_errors` (`{name: stderr text}`, each under `ORG`) with - that text: the environment for the run, and the log of every call. - Nothing it answers reaches GitHub.""" + organisation's GitHub plan when one is given, or failing that question + with the text `plan_error`, and failing `repo view` of each name in + `view_errors` (`{name: stderr text}`, each under `ORG`) with that text, + or only its `--json visibility` for each name in `visibility_errors`: + the environment for the run, and the log of every call. Nothing it + answers reaches GitHub.""" fakebin = tmp_path / "fakebin" fakebin.mkdir() gh = fakebin / "gh" @@ -378,9 +393,13 @@ def fake_gh(tmp_path, repos: dict, full=(), plan: str | None = None, "FAKE_GH_FULL": ",".join(f"{ORG}/{name}" for name in full)} if plan is not None: env["FAKE_GH_PLAN"] = plan - if view_errors: - env["FAKE_GH_VIEW_ERRORS"] = json.dumps( - {f"{ORG}/{name}": text for name, text in view_errors.items()}) + if plan_error is not None: + env["FAKE_GH_PLAN_ERROR"] = plan_error + for key, errors in (("FAKE_GH_VIEW_ERRORS", view_errors), + ("FAKE_GH_VISIBILITY_ERRORS", visibility_errors)): + if errors: + env[key] = json.dumps( + {f"{ORG}/{name}": text for name, text in errors.items()}) return env, log