Files
strukto-ai--mirage/python/mirage/core/github/repo.py
T
bytecii ca89dc5970 fix(parity): github ref default, NAME_MAX byte budget, browser registry onto node's config shape
Two py<->ts divergences, one structural alignment, one swept-and-gated class
of config noise.

1. GitHub `ref` defaulted to the literal "main" in python where TypeScript
   resolves `config.ref ?? repoInfo.default_branch`, so every repository whose
   default branch is something else 404d on the one tree fetch the mount is
   built on and read as empty. `GitHubConfig.ref` is now optional, and
   `ensure_ref` resolves it lazily beside the existing `ensure_default_branch`
   -- python's constructor deliberately does no network. Every reader that
   needs a concrete ref goes through it. `mirage.accessor.github` is fully
   annotated and leaves mypy's untyped-defs allowlist.

2. `sanitize_label` capped characters where NAME_MAX counts bytes, which the
   constant's own comment already spelled out. gdocs/gsheets/gslides rendered
   ~367-byte filenames for a CJK title and gmail/email ~269; ext4 and APFS
   reject those. `sanitize_label`/`sanitizeLabel` grew a byte budget and every
   compose site passes what the date, id and suffix leave. Found alongside: TS
   email's hand-rolled copy of the sanitizer used JS's ASCII-only `\w` (so
   non-ASCII subjects became underscores there and stayed intact in python),
   and three sites re-composed `<subject>__<id>.<ext>` independently of
   readdir, so a search hit could name a path that does not exist.

3. The browser registry hand-rolled `normalizeFields` per entry and cast
   through 7 `*BrowserCtorConfig` interfaces -- hand-written twins of the real
   config types. It now delegates to a `normalizeXConfig` beside each config,
   as node's registry does; the interfaces and 22 double casts are gone.

4. 95 rename entries that only restated `snakeToCamel` deleted across 24
   files, with `scripts/check_normalize_renames.py` and a CI step to keep them
   from returning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:34:03 -07:00

186 lines
6.6 KiB
Python

# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import base64
from dataclasses import dataclass
from mirage.accessor.github import GitHubAccessor
from mirage.core.github.client import (GitHubApiError, github_get,
github_request)
from mirage.core.github.config import GhConfig, GitHubConfig
from mirage.types import JsonValue
@dataclass(frozen=True, slots=True)
class RepoRef:
owner: str
repo: str
async def fetch_default_branch(config: GitHubConfig, owner: str,
repo: str) -> str:
data = await github_get(config.token,
"/repos/{owner}/{repo}",
base_url=config.base_url,
owner=owner,
repo=repo)
return data["default_branch"]
async def ensure_default_branch(accessor: GitHubAccessor, ) -> str:
"""Fetch the repo's default branch once, on the first read needing it.
The mount names a repository without contacting it, so this is the
hydration point for the one caller that compares against the default
branch (grep's code-search push-down, which GitHub only serves
there).
Args:
accessor (GitHubAccessor): the mount's accessor.
Returns:
str: the repository's default branch.
"""
if accessor.default_branch is not None:
return accessor.default_branch
async with accessor.branch_lock:
if accessor.default_branch is None:
accessor.default_branch = await fetch_default_branch(
accessor.config, accessor.owner, accessor.repo)
return accessor.default_branch
async def ensure_ref(accessor: GitHubAccessor) -> str:
"""Settle which ref this mount reads, fetching the default branch once.
A mount that named no ref follows the repository's default branch, and
learning that costs a request the constructor cannot make. Every reader
that needs a concrete ref -- the tree fetches, the watch walk, readdir's
per-directory descent -- goes through here instead of reading
``accessor.ref`` directly, so an unpinned mount resolves exactly once and
then behaves like a pinned one.
Defaulting to the string ``"main"`` instead was the bug this replaces: a
repository whose default branch is ``master`` (or anything else) 404s on
every tree fetch, so the whole mount reads as empty.
Args:
accessor (GitHubAccessor): the mount's accessor.
Returns:
str: the ref to read, as named by the mount or as resolved from the
repository's default branch.
"""
if accessor.ref is not None:
return accessor.ref
resolved = await ensure_default_branch(accessor)
accessor.ref = resolved
return resolved
def parse_repo(spec: str) -> RepoRef:
"""Split gh's `[HOST/]OWNER/REPO`.
The host is optional and leading, so the owner and the repository are
always the last two segments. Taking the first two instead reads
`github.com/acme/tools` as owner `github.com`, repo `acme` -- a
different repository, reported as success.
Args:
spec (str): the repository as the line spelled it.
Returns:
RepoRef: the owner and repository names.
Raises:
ValueError: the spec is not one or two slashes of names.
"""
parts = spec.split("/")
# One extra segment is a host; two is not a repository any spelling
# of gh's format reaches.
if len(parts) not in (2, 3) or not all(parts[-2:]):
raise ValueError(
f'expected the "[HOST/]OWNER/REPO" format, got "{spec}"')
return RepoRef(owner=parts[-2], repo=parts[-1])
async def login(config: GhConfig) -> str:
"""The authenticated account's login name.
Args:
config (GhConfig): the install's configuration.
Returns:
str: the login, empty when the account reports none.
"""
me = await github_request(config.token,
"GET",
"/user",
base_url=config.base_url)
name = me.get("login") if isinstance(me, dict) else None
return name if isinstance(name, str) else ""
async def view_repo(config: GhConfig, ref: RepoRef) -> JsonValue:
return await github_request(config.token,
"GET",
f"/repos/{ref.owner}/{ref.repo}",
base_url=config.base_url)
async def read_readme(config: GhConfig, ref: RepoRef) -> str | None:
"""The repository's README as text, or None when it has none.
Args:
config (GhConfig): the install's configuration.
ref (RepoRef): the repository.
Returns:
str | None: the decoded README, None when the repo has none.
"""
try:
data = await github_request(config.token,
"GET",
f"/repos/{ref.owner}/{ref.repo}/readme",
base_url=config.base_url)
except GitHubApiError as exc:
if exc.status == 404:
return None
raise
if not isinstance(data, dict):
return None
content = data.get("content")
if not isinstance(content, str):
return None
return base64.b64decode(content).decode("utf-8", "replace")
async def fork_repo(config: GhConfig,
ref: RepoRef,
name: str | None = None) -> JsonValue:
body: JsonValue = {} if name is None else {"name": name}
return await github_request(config.token,
"POST",
f"/repos/{ref.owner}/{ref.repo}/forks",
body,
base_url=config.base_url)
async def rename_repo(config: GhConfig, ref: RepoRef, name: str) -> JsonValue:
return await github_request(config.token,
"PATCH",
f"/repos/{ref.owner}/{ref.repo}",
{"name": name},
base_url=config.base_url)