Skip to content
Merged
65 changes: 28 additions & 37 deletions adopt-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,6 @@
import contextlib
import datetime as _dt
import itertools
import json
import os
import re
import shlex
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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).

Expand All @@ -9235,41 +9227,40 @@ 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:
"""`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, 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,
Expand Down
51 changes: 27 additions & 24 deletions scaffold-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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).
Expand Down Expand Up @@ -223,8 +227,10 @@ def remote_is_empty(repository: str) -> bool | None:
def remote_visibility(repository: str) -> str | None:
"""`<org>/<repo>`'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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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])
Expand Down Expand Up @@ -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")]
Expand All @@ -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:
Expand Down
54 changes: 42 additions & 12 deletions scripts/family.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 "<holder>
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:
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading