feat(gh): a GitHub CLI, and the write half of the integ GitHub fake (#768)
* feat(integ/github): repo, contents, issues and compare routes
The fake served the read path the `github` resource mounts -- repo
metadata, git/trees, git/blobs -- plus the Actions dataset `github_ci`
needs. A task that *acts* on a repository had nothing to call.
Adds the routes such a task uses: GET /user; POST /user/repos; DELETE
/repos/{owner}/{repo}; POST .../forks; GET .../branches and
.../branches/{branch}; GET .../commits; GET and PUT .../contents/{path};
GET and POST .../issues; GET .../compare/{basehead}.
Writes are visible to the next read, which is the point -- an issue
filed shows up in the listing, a committed file reads back with its new
bytes and a new blob sha, and the commit is recorded so `compare`
can answer which files moved. `FakeRepo` grows `issues` and `commits`
for that; a repository with no writes yet still reports one synthetic
root commit, so "the latest commit" is answerable before the agent has
done anything.
Three behaviours worth pinning rather than leaving to chance:
- A fork deep-copies. A task that forks an archive repo per run and then
commits to the fork must not write through to the source.
- PUT /contents enforces GitHub's sha rule in both directions: replacing
an existing file without the current blob sha is 409, and supplying
one for a file that does not exist is 422. A task that reads before
writing is doing so for this reason.
- A compare against a base the repository has never seen is 404, not an
empty file list. Answering "nothing changed" to a question about an
unrelated commit is the shape of wrongness that reads as success.
`_commit_list` is newest-first, so `compare` collects the commits
*before* it reaches the base; walking past the base instead would report
the commits the base already contains.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(integ/github): git data API, rename, raw content and GHES paths
The write half a task that builds a repository needs, plus the two shapes
a real client insists on:
- PATCH /repos/{owner}/{repo} renames, carrying the content with it, and
POST /forks takes a name so a fork can be named in one step.
- The git data API -- get commit, create tree, create commit, move ref --
so a multi-file push lands as one commit. A staged tree is invisible
until a ref points at it, which is what git does and what keeps an
abandoned tree from showing up in a read.
- GET /git/ref/{ref}, and trees by commit sha, because a client resolves
a ref to a commit before reading anything.
- /raw/{owner}/{repo}/{ref}/{path}, with a text content type for text, so
a reader does not base64 the whole repository.
- Every route is served under /api/v3 as well as at the root: a client
pointed at a host that is not github.com talks Enterprise.
- GET /search/repositories, and an unrouted fallback that names the
method and path on stderr. Both exist for the same reason: a 404 is an
answer to a real client, so an unimplemented endpoint reads as a
negative result rather than as an error.
- Seeding takes a per-repo default branch, since a template whose branch
is master is graded at that ref by name.
* feat(gh): a GitHub CLI, and the write routes the fake needs behind it
The `github` mount is the read half -- a repository is a tree, so listing
and reading it is `ls` and `cat`. There was no write half at all:
GITHUB_IO carries readdir/read/stat where GDRIVE_IO carries the full set,
so nothing could commit a file, and forking and renaming are account
operations a filesystem has no shape for in any case.
`gh` is that half, spelled as cli.github.com spells it:
gh repo view [OWNER/REPO]
gh repo fork OWNER/REPO [--fork-name NAME]
gh repo rename NEW-NAME -R OWNER/REPO
gh api ENDPOINT [-X METHOD] [-f key=value] [-F key=value]
`rename` takes the new name as the operand and the repository as -R, which
is the reverse of what the shape of the line suggests and is upstream's.
`-f` sends a string and `-F` reads true/false/null/integers as their JSON
types, which is gh's own --raw-field / --field split; a call with no fields
is a GET, one with fields a POST unless -X says otherwise. Committing a
file is `gh api ... -X PUT -f content="$(base64 -w0 f)"`, which is what
real gh and real GitHub require of each other -- there is no file verb to
mimic.
The transport was GET-only, so it grows a `request`, and an empty body (204,
an empty 202) decodes to null rather than throwing on a call that worked.
Fake, in support: DELETE /contents with the same sha rule as the replace,
and GET /commits/{ref}, which is not /git/commits/{sha} -- it takes a branch
name and reports the file list. Both were found by the unrouted logger
during a run: an agent issued 35 deletes, every one 404'd, and it reported
the files as removed, because to a real client a 404 is an answer.
* feat(integ/github): repository metadata, star-sorted search and readme
A task that picks *between* repositories reads their descriptions and star
counts and never opens one, so seeding gains a second mode: --metadata takes
a JSON file keyed by owner/name and creates repositories that are metadata
only. The tree and contents endpoints answer 404 for them, which is what
GitHub says about a repository with no files.
search/repositories honours sort=stars and matches the description as well
as the name, since a repository is found by what it says it does at least as
often as by what it is called. Terms OR rather than AND, which is looser
than GitHub and errs towards showing a caller the row it wants.
GET /repos/{owner}/{repo}/readme, found by the unrouted logger during a run.
* feat(integ/github): branches
The fake kept exactly one branch, which rules out every task whose premise
is a difference between two of them. FakeRepo now holds a file map and a
commit list per branch:
- `files` and `commits` stay bound to the default branch, so every route
that does not name a ref reads what it always read.
- `branch_for` resolves a ref -- a branch name, HEAD, or a commit sha
belonging to one branch's history -- and `tree_of` returns that
branch's files, so a bad ref is a 404 rather than a silent read of the
default branch.
- contents, tree, raw, readme, branches, branch, commits, commit and
git/ref are ref-aware; PUT and DELETE /contents write to the branch
their body names; PATCH /git/refs moves the branch it names.
- Each branch has its own synthetic root commit, so two branches of a
fixture do not share a head.
- A fork copies every branch, not just the default one.
- Seeding takes `into=<branch>`, so a two-branch fixture is two
directories and two --repo flags naming one repository.
The default-branch binding is what keeps this small: 18 call sites read
`repo.files` and none of them had to change.
* feat(integ/github): GET /git/refs/{prefix}
The plural is a different endpoint from the singular: git/ref/<full-ref>
returns one object, git/refs/<prefix> a list of everything beneath it. A
caller picks whichever it expects, so serving only the singular made the
plural read as 'no such ref'. Found by the unrouted logger during a run.
* feat(integ/github): account repo listing, bare /contents, mirror-stable shas
Three things a task that reasons across several repositories needs.
GET /users/{login}/repos and GET /user/repos. A task that asks "is there a
repository for this on my GitHub" answers it here, and a 404 read as "the
account has none" -- a wrong answer rather than an error, which is the
failure mode the unrouted logger exists for.
GET /repos/{owner}/{repo}/contents, with no trailing slash. GitHub serves
both spellings of the root and a caller picks either; only one was routed.
A branch's root commit sha is now derived from its content rather than from
the repository's name. That is what `git clone --mirror` followed by a push
actually gives you -- the same shas -- and a grader that reads an initial
sha from an upstream repository and the latest from a local copy, then asks
what changed between them, depends on it. Two branches still differ exactly
when their trees differ.
* feat(integ/github): POST /git/refs, so a branch can be created
The last write a task can ask for that the fake had no answer to. A new
branch starts as a copy of whatever the base sha resolves to -- which is
what a branch is, another name for one commit and everything reachable from
it -- with its own empty history, so the two do not share future commits.
422 on a ref that is not under refs/heads/, on a name that already exists,
and on a base sha that resolves to nothing.
* feat(integ/github): GET /, the API root
github.com answers the root with a map of endpoint URL templates. Serving
only the endpoints beneath it meant a client probing the root got a 404,
which reads as the host not being there at all rather than as an unfamiliar
API. Found by the unrouted logger during a run.
* feat(integ/github): seed a branch's commit history, not just its tree
Seeding a directory filled a tree and nothing else, so every seeded
branch answered GET /commits with one synthetic root. That is enough for
a task that reads files and wrong for one that asks when something
arrived. `task-tracker` wants the tasks added by the *most recent*
commit on each of fifteen developer branches, and each branch has five
or six -- so a history of one does not make the task hard, it makes it
unanswerable while looking answerable.
--commits owner/name=<file> reads a manifest keyed by branch, each an
array oldest first, and is applied after every tree: a manifest names
branches, and a branch no directory was seeded into would otherwise be
created here as an empty tree with a history, which reads as a branch
whose files were all deleted.
Shas are derived from full_name, branch, date and message rather than
carried, for the same reason the root's is derived from its tree: a
fixture has to answer the same way twice.
Commits now carry commit.author/committer (name, email, date) and
author.login, which is what a client sorting or filtering by date reads,
and the synthetic root carries them too against a fixed epoch -- it
stands for everything before the fixture rather than for a moment, so
"now" would make it sort ahead of the commits it precedes.
GET /commits/{ref} now renders `files` as {filename, status} objects the
way GitHub does. The list endpoint keeps paths, which is also GitHub's
split: `files` is absent from a commit in a list and detailed when one
commit is asked for. _commit_files is shared with the compare route,
which was building the same objects inline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(github): back the transport with octokit
Wraps @octokit/core behind the unchanged GitHubTransport, so the github
mount and the gh CLI both gain retry and throttling. Handles three
octokit behaviours: {} in a url is a route template that silently eats
the segment, the "METHOD /path" form splits on whitespace, and 204
decodes to '' rather than null. The write throttle is github.com's own
secondary rate limit, so it is off for any other host.
* fix(gh): name the mount its writes invalidate, and parse the host segment
serves was empty, so the executor's post-write cache drop did nothing and
a committed file still read back as its pre-write bytes. parseRepo took
the first two segments of [HOST/]OWNER/REPO, so github.com/acme/tools
resolved to github.com/acme -- a different repository, reported as
success.
* fix(cache): invalidate the index instead of clearing it
A cleared index reads exactly like one that was never filled, so github --
whose index is the whole listing rather than a cache in front of one --
could not tell a cache drop from an empty repository, and ls reported the
mount root missing. IndexCacheStore grows invalidate(), which expires
entries in place, and github refetches once on an EXPIRED lookup. Also
fixes a latent bug with no CLI involved: after the 24h index TTL lapsed a
github mount answered ls with exit 0 and no output. Redis cannot express
stale (an expired key is gone), so it still clears; the comment says why.
* feat(gh): a python gh CLI, mirroring the typescript one
Same four verbs and the same grammar. The python client was GET-only, so
it grows github_request alongside github_get. Layout divergence 305 -> 303.
* test(integ): a gh CLI battery on both hosts
15 cases on a new cli-gh target, covering the write-then-read path through
the mount, the refusals, and the reversed rename grammar. The fake grows
POST /reset, because the cli facet runs both hosts against one process and
writes would otherwise leak between them; it also stops rejecting wrapped
base64, which real GitHub accepts. The unrouted-request logger is now a CI
failure rather than a log line.
* fix(github): probe the parent listing, so an update is not read back stale
The index tracks freshness per directory, never per entry, so get()
never answers EXPIRED and the refill it guarded was unreachable: after a
write invalidated the index the blob's row survived carrying the
pre-write sha, and the read served the old bytes. Create and delete went
through readdir and worked; an update did not.
Readdir on the TypeScript side refilled on any miss, which spent a full
recursive-tree call on every ENOENT and diverged from python. Both now
refill only on EXPIRED.
* test(cache): pin what invalidate keeps that clear discards
A cleared store reads exactly like one that was never filled, which is
what made a github mount report an empty repository. Assert the two
answer differently.
* fix(spec): render an operand's declared name outside the clap dialect
A named slot printed as <text>, so 'man gh api' said <text> where gh
says <endpoint>. argparse prints the dest too. gh is the only spec this
changes; ntn is clap and already rendered it.
* test(integ): cover the gh surface the battery missed
Update-then-read is the case that found the stale-read bug. Also the
help and discovery surface, a leading-slash endpoint, typed fields on a
GET, and a missing endpoint.
* docs(gh): a page per host for the gh CLI
Install, the mount-reads/CLI-acts split, the api rules, and a table of
what differs from gh 2.85.
* fix(github): keep an api field from steering the request
Octokit reads loose parameters off the same object that carries url,
method and headers, so `gh api X -f url=...` retargeted the call
instead of sending the field. The query is spelled into the url and the
body travels as `data`. The python client already separated them.
* fix(cli): let a handler say it did not write
A leaf declares `write` statically because for almost every verb it is
static, but `gh api` carries its method on the line, so a read like
`gh api /user` expired every github mount the install serves. A result
may now report what the spec cannot know.
* feat(gh): expand the owner, repo and branch placeholders
gh's own examples are written with them, so `gh api
repos/{owner}/{repo}/releases` asked for literal braces and 404'd. An
install's repo and branch stand in for the current checkout. Any other
brace pair still reaches the wire, which is gh's behavior too.
* feat(gh): render repo view the way gh renders it
A name line, a description line, then the README, with the separator
omitted when there is none. Probed against gh 2.85. The REST object is
still one `gh api repos/OWNER/REPO` away.
* test(integ): cover placeholders and the new repo view
Also carries a branch on the gh install, which is what {branch}
expands from.
* fix(gh): declare branch on the GhConfig type, and type the test trees
tsc runs in CI but not in pre-commit: GhConfig's zod schema grew
`branch` while its interface did not, and the new github tests built
tree items with a `mode` key the type has no room for.
* refactor(gh): derive GhConfig from its schema instead of declaring it twice
The zod schema is the one doing real work: it validates an install's
config and carries the secretStr marker redaction reads. A hand-written
interface beside it only adds a shape that can drift, which is how
branch reached the schema and not the type.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,7 @@ runs:
|
||||
nohup ./python/.venv/bin/python integ/server/github_server.py \
|
||||
--port 5098 --repo integ/repo-v1=github/repo-v1 \
|
||||
--repo integ/repo-trunc=github/repo-v1:truncated \
|
||||
--repo integ/repo-cli=github/repo-v1 \
|
||||
> /tmp/ghsrv.log 2>&1 &
|
||||
for i in $(seq 1 30); do
|
||||
grep -q "GITHUB_ENDPOINT=" /tmp/ghsrv.log && break
|
||||
|
||||
@@ -1277,6 +1277,16 @@ jobs:
|
||||
cat /tmp/discord.log
|
||||
echo "DISCORD_ENDPOINT=http://127.0.0.1:5099" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start fake GitHub API
|
||||
if: matrix.facet == 'cli'
|
||||
run: |
|
||||
nohup ./python/.venv/bin/python integ/server/github_server.py \
|
||||
--port 5098 --repo integ/repo-cli=github/repo-v1 \
|
||||
> /tmp/github.log 2>&1 &
|
||||
for i in $(seq 1 30); do grep -q "GITHUB_ENDPOINT=" /tmp/github.log && break; sleep 1; done
|
||||
cat /tmp/github.log
|
||||
echo "GITHUB_URL=http://127.0.0.1:5098" >> "$GITHUB_ENV"
|
||||
|
||||
# The ntn and linear CLIs ride the notion mock (self-started by each
|
||||
# runner under NOTION_ENABLED) and the fake Linear GraphQL server.
|
||||
- name: Start fake Linear API and the Notion mock
|
||||
@@ -1311,6 +1321,21 @@ jobs:
|
||||
working-directory: integ
|
||||
run: pnpm exec tsx runners/typescript/main.ts --facet ${{ matrix.facet }} --strict
|
||||
|
||||
# A 404 is an answer to a real client -- "no such repository", "no such
|
||||
# file" -- so an endpoint the fake does not implement is indistinguishable
|
||||
# from a negative result, and what comes out is a confident wrong
|
||||
# conclusion rather than an error. The fake names every unrouted request
|
||||
# on stderr; this is what makes that a failure rather than a log line.
|
||||
- name: Fail on any unrouted GitHub request
|
||||
if: matrix.facet == 'cli'
|
||||
run: |
|
||||
if grep -q "no route for" /tmp/github.log; then
|
||||
echo "The gh battery reached endpoints the fake does not implement:"
|
||||
grep "no route for" /tmp/github.log | sort -u
|
||||
exit 1
|
||||
fi
|
||||
echo "no unrouted GitHub requests"
|
||||
|
||||
- name: Stop GreenMail
|
||||
if: always() && (matrix.facet == 'email' || matrix.facet == 'cli')
|
||||
run: docker rm -f mirage-greenmail || true
|
||||
|
||||
@@ -392,6 +392,7 @@
|
||||
"python/cli/discord",
|
||||
"python/cli/ntn",
|
||||
"python/cli/linear",
|
||||
"python/cli/gh",
|
||||
"python/cli/git"
|
||||
]
|
||||
},
|
||||
@@ -582,6 +583,7 @@
|
||||
"typescript/cli/discord",
|
||||
"typescript/cli/ntn",
|
||||
"typescript/cli/linear",
|
||||
"typescript/cli/gh",
|
||||
"typescript/cli/git"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
title: gh
|
||||
description: Act on GitHub in the official CLI's vocabulary, alongside a github mount that reads the repository as files.
|
||||
icon: terminal
|
||||
---
|
||||
|
||||
Act on GitHub in the official [CLI](https://cli.github.com)'s vocabulary.
|
||||
A repository is a tree, so mirage already reads one as files: the
|
||||
[`github` mount](/python/setup/github) is the read half, and `ls`, `cat`
|
||||
and `grep` are how an agent explores it. `gh` is the write half, plus the
|
||||
account-level operations a filesystem has no shape for.
|
||||
|
||||
## Install
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
from mirage import Workspace
|
||||
from mirage.commands.cli.builtin.gh import GH
|
||||
from mirage.core.github.config import GhConfig, GitHubConfig
|
||||
from mirage.resource.github import GitHubResource
|
||||
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
repo = await GitHubResource.build(
|
||||
config=GitHubConfig(token=token), owner="acme", repo="tools", ref="main")
|
||||
|
||||
ws = Workspace({"/repo": repo})
|
||||
ws.register_cli("gh", GH, GhConfig(token=token, repo="acme/tools"))
|
||||
|
||||
await ws.execute("gh repo view")
|
||||
```
|
||||
|
||||
In YAML the same install rides the `clis:` section; see the
|
||||
[CLI overview](/python/cli/index).
|
||||
|
||||
| Field | Meaning |
|
||||
| ---------- | ------------------------------------------------------------ |
|
||||
| `token` | the API token, as `GH_TOKEN` carries for real gh |
|
||||
| `repo` | the default repository, as `[HOST/]OWNER/REPO` |
|
||||
| `branch` | the current branch, for `{branch}` in an endpoint |
|
||||
| `base_url` | the API base, for GitHub Enterprise Server |
|
||||
|
||||
`repo` and `branch` are what real gh reads off the current directory's git
|
||||
remote and checkout. A workspace has neither, so the install carries them:
|
||||
`repo` answers a line that names no repository, and both feed the
|
||||
`{owner}`/`{repo}`/`{branch}` placeholders. Two installs under different head words are two
|
||||
accounts.
|
||||
|
||||
## Reading is the mount, acting is the CLI
|
||||
|
||||
```bash
|
||||
ls /repo/src # the tree, from the mount
|
||||
cat /repo/README.md # a blob, from the mount
|
||||
grep -r TODO /repo # the mount again
|
||||
|
||||
gh api repos/acme/tools/contents/README.md -X PUT \
|
||||
-f message='docs: fix a typo' -f content="$(base64 -w0 new.md)" -f sha=<blob-sha>
|
||||
```
|
||||
|
||||
A write through `gh` lands on the same repository the mount reads, but it
|
||||
lands **by repository name rather than by any vfs path**, so the mount has
|
||||
nothing to aim a per-path invalidation at. The spec declares which
|
||||
resource it serves, and the executor expires that mount's index after the
|
||||
line, so the next `cat` or `ls` refetches instead of serving the pre-write
|
||||
bytes. Nothing is required of the caller.
|
||||
|
||||
## Verbs
|
||||
|
||||
```
|
||||
gh repo view [<REPOSITORY>]
|
||||
gh repo fork [<REPOSITORY>] --fork-name
|
||||
gh repo rename <NEW-NAME> -R/--repo
|
||||
gh api <ENDPOINT> -X/--method -f/--raw-field -F/--field
|
||||
```
|
||||
|
||||
Every level answers `--help`, and `man gh`, `man gh repo` and
|
||||
`man gh api` render the same text from the same spec.
|
||||
|
||||
### repo
|
||||
|
||||
```bash
|
||||
gh repo view # the install's repository
|
||||
gh repo view acme/tools
|
||||
gh repo view github.com/acme/tools # the host segment is accepted
|
||||
|
||||
gh repo fork acme/tools
|
||||
gh repo fork acme/tools --fork-name tools-patched
|
||||
|
||||
gh repo rename tools-v2 -R acme/tools
|
||||
```
|
||||
|
||||
`view` prints what gh prints: a `name:` line, a `description:` line, then
|
||||
`--` and the README, with the separator omitted when the repository has
|
||||
none. For the repository object as JSON, use `gh api repos/OWNER/REPO`.
|
||||
|
||||
`rename` takes the **new name** as the operand and the repository to
|
||||
rename on `-R`, which is the reverse of what the shape of the line
|
||||
suggests; that is upstream's grammar, not a mirage choice.
|
||||
|
||||
`[HOST/]OWNER/REPO` is parsed from the right, so the owner and the
|
||||
repository are the last two segments and a leading host is dropped. The
|
||||
host is accepted for compatibility with lines copied from real gh but
|
||||
does **not** route: every call goes to the install's `base_url`. A second
|
||||
host means a second install.
|
||||
|
||||
### api
|
||||
|
||||
`gh api` reaches every endpoint that has no typed verb, which is most of
|
||||
them.
|
||||
|
||||
```bash
|
||||
gh api repos/acme/tools
|
||||
gh api /user
|
||||
gh api repos/acme/tools/issues -f title='Bug' -f body='Steps...'
|
||||
gh api -X GET search/code -f q='repo:acme/tools TODO'
|
||||
gh api repos/acme/tools/issues -F draft=false -F milestone=3
|
||||
gh api repos/acme/tools/contents/NOTES.md -X DELETE -f message=rm -f sha=<blob-sha>
|
||||
gh api graphql -f query='{viewer{login}}'
|
||||
|
||||
gh api 'repos/{owner}/{repo}/releases'
|
||||
gh api 'repos/{owner}/{repo}/branches/{branch}'
|
||||
```
|
||||
|
||||
`{owner}`, `{repo}` and `{branch}` expand from the install, the way real
|
||||
gh expands them from the current repository. Any other brace pair is left
|
||||
exactly as typed and reaches the wire, which is gh's behavior too. Quote
|
||||
the endpoint so the shell does not eat the braces.
|
||||
|
||||
The rules are gh's own:
|
||||
|
||||
- The method is `GET` with no fields and `POST` once a field is given,
|
||||
unless `-X` says otherwise.
|
||||
- A `GET` carries its fields in the **query string**; every other method
|
||||
carries them in a **JSON body**.
|
||||
- `-f/--raw-field` is always a string. `-F/--field` reads `true`, `false`,
|
||||
`null` and integers as their JSON types.
|
||||
- A call with no fields sends **no body at all**, so a bare `DELETE` is a
|
||||
bare `DELETE` rather than an empty JSON object with a content type. Some
|
||||
endpoints read those differently.
|
||||
- The leading slash is optional.
|
||||
- A placeholder expands in an endpoint and in a `-F` value, but not in a
|
||||
`-f` one, which is the split gh's own `--help` describes.
|
||||
- A read (`GET`, `HEAD`, `OPTIONS`) leaves the mount's cache alone; only a
|
||||
write expires it.
|
||||
|
||||
Output is JSON on stdout, so the rest of the shell composes with it:
|
||||
|
||||
```bash
|
||||
gh api repos/acme/tools | jq -r .default_branch
|
||||
gh api repos/acme/tools/issues | jq -r '.[].title'
|
||||
```
|
||||
|
||||
## Divergences from upstream gh
|
||||
|
||||
`gh` is virtualized, not wrapped, so the table below is the whole of what
|
||||
differs. Everything else matches `gh` 2.85.
|
||||
|
||||
| Divergence | Why |
|
||||
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `gh api` pretty-prints the response; real gh prints the body verbatim | the reader is usually an agent or `jq`, and an indented body is what a human reads |
|
||||
| `gh repo view` has no `--json`/`-q`/`-t`; the default text view matches | upstream's field set is GraphQL's, with 75 names; `gh api repos/O/R` is the JSON route |
|
||||
| `--paginate`, `-q/--jq`, `-H`, `--input`, `--silent`, `-i` are not built | not built yet; pipe to `jq` for selection, and paginate with `-f page=2` |
|
||||
| `-F` reads no `@file` values, and `key[sub]=` / `key[]=` nesting is flat | not built yet |
|
||||
| `repo rename` has no `-y/--yes` | there is no prompt to skip: nothing in a workspace is interactive |
|
||||
| `repo fork` has no `--clone`, `--remote`, `--org` | those act on a host checkout and a git remote, which a workspace has neither of |
|
||||
| a `HOST/` prefix is accepted but never routes | one install is one account on one host; a second host is a second install |
|
||||
|
||||
The interactive and host-side verbs (`auth`, `gist`, `codespace`,
|
||||
`browse`, `repo clone`) are out of scope for a virtualized CLI, the same
|
||||
way they are for the other account CLIs.
|
||||
@@ -171,6 +171,7 @@ verb for it, so an agent cannot uninstall the tools it was given.
|
||||
| [discord](/python/cli/discord) | Discord | OpenClaw Discord actions (`send`, `poll`, …)|
|
||||
| [ntn](/python/cli/ntn) | Notion | official Notion CLI (`pages`, `datasources`)|
|
||||
| [linear](/python/cli/linear) | Linear | noun/verb (`issue create`, `team list`) |
|
||||
| [gh](/python/cli/gh) | GitHub | official GitHub CLI (`repo`, `api`) |
|
||||
| [git](/python/cli/git) | git repositories | git (`status`, `log`, `add`, `commit`) |
|
||||
|
||||
Reading stays on the mount (`cat`, `grep`, `jq` over the virtual
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
title: gh
|
||||
description: Act on GitHub in the official CLI's vocabulary, alongside a github mount that reads the repository as files.
|
||||
icon: terminal
|
||||
---
|
||||
|
||||
Act on GitHub in the official [CLI](https://cli.github.com)'s vocabulary.
|
||||
A repository is a tree, so mirage already reads one as files: the
|
||||
[`github` mount](/typescript/setup/github) is the read half, and `ls`,
|
||||
`cat` and `grep` are how an agent explores it. `gh` is the write half,
|
||||
plus the account-level operations a filesystem has no shape for.
|
||||
|
||||
## Install
|
||||
|
||||
```typescript
|
||||
import { GH } from '@struktoai/mirage-core'
|
||||
import { GitHubResource, Workspace } from '@struktoai/mirage-node'
|
||||
|
||||
const config = { token: process.env.GITHUB_TOKEN!, repo: 'acme/tools' }
|
||||
const repo = await GitHubResource.create({
|
||||
token: config.token,
|
||||
owner: 'acme',
|
||||
repo: 'tools',
|
||||
ref: 'main',
|
||||
})
|
||||
|
||||
const ws = new Workspace({ '/repo': repo })
|
||||
ws.registerCli('gh', GH, config)
|
||||
|
||||
await ws.execute('gh repo view')
|
||||
```
|
||||
|
||||
In YAML the same install rides the `clis:` section; see the
|
||||
[CLI overview](/typescript/cli/index).
|
||||
|
||||
| Field | Meaning |
|
||||
| --------- | ------------------------------------------------------------- |
|
||||
| `token` | the API token, as `GH_TOKEN` carries for real gh |
|
||||
| `repo` | the default repository, as `[HOST/]OWNER/REPO` |
|
||||
| `branch` | the current branch, for `{branch}` in an endpoint |
|
||||
| `baseUrl` | the API base, for GitHub Enterprise Server |
|
||||
|
||||
`repo` and `branch` are what real gh reads off the current directory's git
|
||||
remote and checkout. A workspace has neither, so the install carries them:
|
||||
`repo` answers a line that names no repository, and both feed the
|
||||
`{owner}`/`{repo}`/`{branch}` placeholders. Two installs under different head words are two
|
||||
accounts.
|
||||
|
||||
## Reading is the mount, acting is the CLI
|
||||
|
||||
```bash
|
||||
ls /repo/src # the tree, from the mount
|
||||
cat /repo/README.md # a blob, from the mount
|
||||
grep -r TODO /repo # the mount again
|
||||
|
||||
gh api repos/acme/tools/contents/README.md -X PUT \
|
||||
-f message='docs: fix a typo' -f content="$(base64 -w0 new.md)" -f sha=<blob-sha>
|
||||
```
|
||||
|
||||
A write through `gh` lands on the same repository the mount reads, but it
|
||||
lands **by repository name rather than by any vfs path**, so the mount has
|
||||
nothing to aim a per-path invalidation at. The spec declares which
|
||||
resource it serves, and the executor expires that mount's index after the
|
||||
line, so the next `cat` or `ls` refetches instead of serving the pre-write
|
||||
bytes. Nothing is required of the caller.
|
||||
|
||||
## Verbs
|
||||
|
||||
```
|
||||
gh repo view [<REPOSITORY>]
|
||||
gh repo fork [<REPOSITORY>] --fork-name
|
||||
gh repo rename <NEW-NAME> -R/--repo
|
||||
gh api <ENDPOINT> -X/--method -f/--raw-field -F/--field
|
||||
```
|
||||
|
||||
Every level answers `--help`, and `man gh`, `man gh repo` and
|
||||
`man gh api` render the same text from the same spec.
|
||||
|
||||
### repo
|
||||
|
||||
```bash
|
||||
gh repo view # the install's repository
|
||||
gh repo view acme/tools
|
||||
gh repo view github.com/acme/tools # the host segment is accepted
|
||||
|
||||
gh repo fork acme/tools
|
||||
gh repo fork acme/tools --fork-name tools-patched
|
||||
|
||||
gh repo rename tools-v2 -R acme/tools
|
||||
```
|
||||
|
||||
`view` prints what gh prints: a `name:` line, a `description:` line, then
|
||||
`--` and the README, with the separator omitted when the repository has
|
||||
none. For the repository object as JSON, use `gh api repos/OWNER/REPO`.
|
||||
|
||||
`rename` takes the **new name** as the operand and the repository to
|
||||
rename on `-R`, which is the reverse of what the shape of the line
|
||||
suggests; that is upstream's grammar, not a mirage choice.
|
||||
|
||||
`[HOST/]OWNER/REPO` is parsed from the right, so the owner and the
|
||||
repository are the last two segments and a leading host is dropped. The
|
||||
host is accepted for compatibility with lines copied from real gh but
|
||||
does **not** route: every call goes to the install's `baseUrl`. A second
|
||||
host means a second install.
|
||||
|
||||
### api
|
||||
|
||||
`gh api` reaches every endpoint that has no typed verb, which is most of
|
||||
them.
|
||||
|
||||
```bash
|
||||
gh api repos/acme/tools
|
||||
gh api /user
|
||||
gh api repos/acme/tools/issues -f title='Bug' -f body='Steps...'
|
||||
gh api -X GET search/code -f q='repo:acme/tools TODO'
|
||||
gh api repos/acme/tools/issues -F draft=false -F milestone=3
|
||||
gh api repos/acme/tools/contents/NOTES.md -X DELETE -f message=rm -f sha=<blob-sha>
|
||||
gh api graphql -f query='{viewer{login}}'
|
||||
|
||||
gh api 'repos/{owner}/{repo}/releases'
|
||||
gh api 'repos/{owner}/{repo}/branches/{branch}'
|
||||
```
|
||||
|
||||
`{owner}`, `{repo}` and `{branch}` expand from the install, the way real
|
||||
gh expands them from the current repository. Any other brace pair is left
|
||||
exactly as typed and reaches the wire, which is gh's behavior too. Quote
|
||||
the endpoint so the shell does not eat the braces.
|
||||
|
||||
The rules are gh's own:
|
||||
|
||||
- The method is `GET` with no fields and `POST` once a field is given,
|
||||
unless `-X` says otherwise.
|
||||
- A `GET` carries its fields in the **query string**; every other method
|
||||
carries them in a **JSON body**.
|
||||
- `-f/--raw-field` is always a string. `-F/--field` reads `true`, `false`,
|
||||
`null` and integers as their JSON types.
|
||||
- A call with no fields sends **no body at all**, so a bare `DELETE` is a
|
||||
bare `DELETE` rather than an empty JSON object with a content type. Some
|
||||
endpoints read those differently.
|
||||
- The leading slash is optional.
|
||||
- A placeholder expands in an endpoint and in a `-F` value, but not in a
|
||||
`-f` one, which is the split gh's own `--help` describes.
|
||||
- A read (`GET`, `HEAD`, `OPTIONS`) leaves the mount's cache alone; only a
|
||||
write expires it.
|
||||
|
||||
Output is JSON on stdout, so the rest of the shell composes with it:
|
||||
|
||||
```bash
|
||||
gh api repos/acme/tools | jq -r .default_branch
|
||||
gh api repos/acme/tools/issues | jq -r '.[].title'
|
||||
```
|
||||
|
||||
## Divergences from upstream gh
|
||||
|
||||
`gh` is virtualized, not wrapped, so the table below is the whole of what
|
||||
differs. Everything else matches `gh` 2.85.
|
||||
|
||||
| Divergence | Why |
|
||||
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `gh api` pretty-prints the response; real gh prints the body verbatim | the reader is usually an agent or `jq`, and an indented body is what a human reads |
|
||||
| `gh repo view` has no `--json`/`-q`/`-t`; the default text view matches | upstream's field set is GraphQL's, with 75 names; `gh api repos/O/R` is the JSON route |
|
||||
| `--paginate`, `-q/--jq`, `-H`, `--input`, `--silent`, `-i` are not built | not built yet; pipe to `jq` for selection, and paginate with `-f page=2` |
|
||||
| `-F` reads no `@file` values, and `key[sub]=` / `key[]=` nesting is flat | not built yet |
|
||||
| `repo rename` has no `-y/--yes` | there is no prompt to skip: nothing in a workspace is interactive |
|
||||
| `repo fork` has no `--clone`, `--remote`, `--org` | those act on a host checkout and a git remote, which a workspace has neither of |
|
||||
| a `HOST/` prefix is accepted but never routes | one install is one account on one host; a second host is a second install |
|
||||
|
||||
The interactive and host-side verbs (`auth`, `gist`, `codespace`,
|
||||
`browse`, `repo clone`) are out of scope for a virtualized CLI, the same
|
||||
way they are for the other account CLIs.
|
||||
@@ -172,6 +172,7 @@ verb for it, so an agent cannot uninstall the tools it was given.
|
||||
| [discord](/typescript/cli/discord) | Discord | OpenClaw Discord actions (`send`, `poll`, …)|
|
||||
| [ntn](/typescript/cli/ntn) | Notion | official Notion CLI (`pages`, `datasources`)|
|
||||
| [linear](/typescript/cli/linear) | Linear | noun/verb (`issue create`, `team list`) |
|
||||
| [gh](/typescript/cli/gh) | GitHub | official GitHub CLI (`repo`, `api`) |
|
||||
| [git](/typescript/cli/git) | git repositories | git (`status`, `log`, `add`, `commit`) |
|
||||
|
||||
Reading stays on the mount (`cat`, `grep`, `jq` over the virtual
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "gh_repo_view_default_repo",
|
||||
"seq": 569000,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh repo view | head -5",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "name:\tinteg/repo-cli\ndescription:\t\n--\n# repo-v1\n\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_repo_view_operand",
|
||||
"seq": 569001,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh repo view integ/repo-cli | head -2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "name:\tinteg/repo-cli\ndescription:\t\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_repo_view_host_prefix_is_dropped",
|
||||
"seq": 569002,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh repo view github.com/integ/repo-cli | head -2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "name:\tinteg/repo-cli\ndescription:\t\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_repo_view_rejects_a_bare_name",
|
||||
"seq": 569003,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh repo view justaname",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "gh repo view: expected the \"[HOST/]OWNER/REPO\" format, got \"justaname\"\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_get_is_the_default_method",
|
||||
"seq": 569004,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api repos/integ/repo-cli | head -3",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"name\": \"repo-cli\",\n \"full_name\": \"integ/repo-cli\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_put_lands_in_the_mount",
|
||||
"seq": 569005,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api repos/integ/repo-cli/contents/NOTES.md -X PUT -f message=add -f content=bmV3IGZpbGUK > /dev/null && cat /repo/NOTES.md",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "new file\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_put_shows_up_in_ls",
|
||||
"seq": 569006,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "ls /repo | head -4",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "NOTES.md\nREADME.md\ndocs\npyproject.toml\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_put_without_a_sha_is_refused",
|
||||
"seq": 569007,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api repos/integ/repo-cli/contents/NOTES.md -X PUT -f message=again -f content=b3RoZXIK",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "gh api: NOTES.md does not match\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_sha_for_a_new_file_is_refused",
|
||||
"seq": 569008,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api repos/integ/repo-cli/contents/ABSENT.md -X PUT -f message=x -f content=eAo= -f sha=deadbeef",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "gh api: Invalid request.\n\n\"sha\" wasn't supplied.\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_delete_drops_the_file_from_the_mount",
|
||||
"seq": 569009,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api repos/integ/repo-cli/contents/NOTES.md -X DELETE -f message=rm -f sha=fa49b077972391ad58037050f2a75f74e3671e92 > /dev/null && ls /repo | head -3",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "README.md\ndocs\npyproject.toml\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_404_on_a_repository_that_is_not_there",
|
||||
"seq": 569010,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api repos/integ/nope",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "gh api: Not Found\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_reads_dash_f_as_json_types",
|
||||
"seq": 569011,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api repos/integ/repo-cli/issues -f title=hi -F draft=false | head -4",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"number\": 1,\n \"title\": \"hi\",\n \"body\": \"\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_refuses_a_field_that_is_not_key_value",
|
||||
"seq": 569012,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api repos/integ/repo-cli -f nope",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "gh api: expected \"key=value\", got \"nope\"\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_repo_fork_names_where_it_landed",
|
||||
"seq": 569013,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh repo fork integ/repo-cli --fork-name forked",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "\u2713 Created fork integ-user/forked\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_repo_rename_takes_the_new_name_as_the_operand",
|
||||
"seq": 569014,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh repo rename renamed -R integ-user/forked",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "\u2713 Renamed repository integ-user/renamed\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_update_then_cat_serves_the_new_bytes",
|
||||
"seq": 569015,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api repos/integ/repo-cli/contents/PROBE.md -X PUT -f message=a -f content=b25lCg== > /dev/null && cat /repo/PROBE.md && gh api repos/integ/repo-cli/contents/PROBE.md -X PUT -f message=b -f content=dHdvCg== -f sha=$(gh api repos/integ/repo-cli/contents/PROBE.md | jq -r .sha) > /dev/null && cat /repo/PROBE.md",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "one\ntwo\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_leading_slash_is_optional",
|
||||
"seq": 569016,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api /repos/integ/repo-cli | head -2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"name\": \"repo-cli\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_typed_fields_reach_a_get_query",
|
||||
"seq": 569017,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api -X GET repos/integ/repo-cli/contents/README.md -F ref=main | head -2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"type\": \"file\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_without_an_endpoint_is_refused",
|
||||
"seq": 569018,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "gh api: an API endpoint is required\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_is_discoverable_as_a_cli",
|
||||
"seq": 569019,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "type -t gh; which gh",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "cli\ngh\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "man_gh_renders_the_spec",
|
||||
"seq": 569020,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "man gh | head -8",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "gh: GitHub CLI\n\nUsage: gh [flags] <command> [<args>]\n\nCommands:\n api Make an authenticated GitHub API request\n repo Manage repositories\n\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "man_gh_api_renders_one_leaf",
|
||||
"seq": 569021,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "man gh api | head -4",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "gh api: Make an authenticated GitHub API request\n\nUsage: gh api [flags] <ENDPOINT>\n\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_repo_rename_answers_help",
|
||||
"seq": 569022,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh repo rename --help | head -4",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "gh repo rename: Rename a repository\n\nUsage: gh repo rename [flags] <NEW-NAME>\n\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_expands_owner_and_repo",
|
||||
"seq": 569023,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api 'repos/{owner}/{repo}' | head -2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"name\": \"repo-cli\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gh_api_expands_a_placeholder_in_a_typed_field",
|
||||
"seq": 569025,
|
||||
"targets": [
|
||||
"cli-gh"
|
||||
],
|
||||
"command": "gh api -X GET repos/integ/repo-cli/contents/README.md -F ref='{branch}' | head -2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"type\": \"file\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -117,6 +117,9 @@ EMAIL_IMAP_PORT = int(os.environ.get("EMAIL_IMAP_PORT", "3143"))
|
||||
EMAIL_SMTP_PORT = int(os.environ.get("EMAIL_SMTP_PORT", "3025"))
|
||||
EMAIL_API_PORT = int(os.environ.get("EMAIL_API_PORT", "8080"))
|
||||
EMAIL_USERNAME = "integ@example.com"
|
||||
# The repository the `gh` install defaults to, standing in for the current
|
||||
# git remote real gh reads. Seeded by the fake alongside the mounted one.
|
||||
GH_CLI_REPO = "integ/repo-cli"
|
||||
EMAIL_PASSWORD = "secret"
|
||||
EMAIL_SENT_FOLDER = "Sent"
|
||||
# Doubles as the workspace id on the fake notion server.
|
||||
@@ -1066,6 +1069,16 @@ class GitHubService:
|
||||
async def create(cls) -> "GitHubService":
|
||||
return cls(os.environ["GITHUB_URL"].rstrip("/"))
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Drop every write since startup, restoring the seeded state.
|
||||
|
||||
The write battery runs once per host against one shared fake, so it
|
||||
starts from the seed rather than from the other host's writes.
|
||||
"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(f"{self.url}/reset") as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
async def resource(self, mount: dict) -> GitHubResource:
|
||||
owner, _, repo = mount["repo"].partition("/")
|
||||
return await GitHubResource.build(
|
||||
@@ -1074,6 +1087,16 @@ class GitHubService:
|
||||
repo=repo,
|
||||
base_url=self.url))
|
||||
|
||||
def cli_installs(self) -> dict[str, tuple[CLISpec, dict[str, object]]]:
|
||||
return {
|
||||
"gh": (cli_spec_for("gh"), {
|
||||
"token": "ghp-integ",
|
||||
"base_url": self.url,
|
||||
"repo": GH_CLI_REPO,
|
||||
"branch": "main",
|
||||
}),
|
||||
}
|
||||
|
||||
async def teardown(self) -> None:
|
||||
return None
|
||||
|
||||
@@ -2147,7 +2170,12 @@ async def make_service(target: dict, run_id: str) -> "Service | None":
|
||||
if target.get("service") == "dropbox":
|
||||
return await DropboxService.create(target)
|
||||
if target.get("service") == "github":
|
||||
return await GitHubService.create()
|
||||
github = await GitHubService.create()
|
||||
# The write battery runs once per host against one shared fake, so
|
||||
# it starts from the seed rather than from the other host's writes.
|
||||
if "gh" in (target.get("clis") or []):
|
||||
await github.reset()
|
||||
return github
|
||||
if target.get("service") == "github_ci":
|
||||
return await GitHubCIService.create()
|
||||
if target.get("service") == "slack":
|
||||
@@ -2221,8 +2249,9 @@ def cli_install(service: "Service | None",
|
||||
if service is None:
|
||||
return cli_spec_for(cli_name), None
|
||||
# Widen the assert when another service grows a CLI.
|
||||
assert isinstance(service, (DiscordService, EmailService, GwsService,
|
||||
LinearService, NotionService, SlackService))
|
||||
assert isinstance(service,
|
||||
(DiscordService, EmailService, GitHubService, GwsService,
|
||||
LinearService, NotionService, SlackService))
|
||||
return service.cli_installs()[cli_name]
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
DISCORD,
|
||||
GWS,
|
||||
HIMALAYA,
|
||||
GH,
|
||||
GIT,
|
||||
LINEAR,
|
||||
NTN,
|
||||
@@ -1367,6 +1368,10 @@ async function openSlack(target: Target): Promise<Open> {
|
||||
return { ws: ws as unknown as ExecWorkspace, cleanup }
|
||||
}
|
||||
|
||||
// The repository the `gh` install defaults to, standing in for the current
|
||||
// git remote real gh reads. Seeded by the fake alongside the mounted one.
|
||||
const GH_CLI_REPO = 'integ/repo-cli'
|
||||
|
||||
// The fake api.github.com server (integ/server/github_server.py) is external
|
||||
// and shared across both hosts, mirroring the fake Slack server. It used to
|
||||
// have to be out of process for the python host, whose GitHubResource
|
||||
@@ -1376,8 +1381,21 @@ async function openGitHub(target: Target): Promise<Open> {
|
||||
let base = process.env.GITHUB_URL ?? ''
|
||||
while (base.endsWith('/')) base = base.slice(0, -1)
|
||||
if (base === '') throw new Error('github target requires GITHUB_URL')
|
||||
const mounts: Record<string, GitHubResource | [GitHubResource, MountMode]> = {}
|
||||
// The write battery runs once per host against one shared fake, so it
|
||||
// starts from the seed rather than from the other host's writes.
|
||||
if (target.clis?.includes('gh') === true) {
|
||||
const reset = await fetch(`${base}/reset`, { method: 'POST' })
|
||||
if (!reset.ok) throw new Error(`github /reset failed: ${String(reset.status)}`)
|
||||
}
|
||||
const mounts: Record<
|
||||
string,
|
||||
GitHubResource | RAMResource | [GitHubResource, MountMode]
|
||||
> = {}
|
||||
for (const m of target.mounts) {
|
||||
if (m.resource === 'ram') {
|
||||
mounts[m.path] = new RAMResource()
|
||||
continue
|
||||
}
|
||||
const [owner, repo] = String(m.repo).split('/')
|
||||
const resource = await GitHubResource.create({
|
||||
token: 'ghp-integ',
|
||||
@@ -1388,6 +1406,14 @@ async function openGitHub(target: Target): Promise<Open> {
|
||||
mounts[m.path] = m.mode === 'read' ? [resource, MountMode.READ] : resource
|
||||
}
|
||||
const ws = new Workspace(mounts, { mode: MountMode.WRITE })
|
||||
if (target.clis?.includes('gh') === true) {
|
||||
ws.registerCli('gh', GH, {
|
||||
token: 'ghp-integ',
|
||||
base_url: base,
|
||||
repo: GH_CLI_REPO,
|
||||
branch: 'main',
|
||||
})
|
||||
}
|
||||
return { ws: ws as unknown as ExecWorkspace, cleanup: () => ws.close() }
|
||||
}
|
||||
|
||||
|
||||
+1553
-50
File diff suppressed because it is too large
Load Diff
@@ -1033,6 +1033,32 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cli-gh",
|
||||
"hosts": [
|
||||
"python",
|
||||
"typescript-node"
|
||||
],
|
||||
"service": "github",
|
||||
"facet": "cli",
|
||||
"clis": [
|
||||
"gh"
|
||||
],
|
||||
"mounts": [
|
||||
{
|
||||
"path": "/repo",
|
||||
"resource": "github",
|
||||
"backend": "github",
|
||||
"repo": "integ/repo-cli",
|
||||
"mode": "read"
|
||||
},
|
||||
{
|
||||
"path": "/scratch",
|
||||
"resource": "ram",
|
||||
"backend": "memory"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "github_ci",
|
||||
"hosts": [
|
||||
|
||||
Vendored
+3
@@ -57,6 +57,9 @@ class NullIndexCacheStore(IndexCacheStore):
|
||||
async def invalidate_dir(self, resource_path: str) -> None:
|
||||
return None
|
||||
|
||||
async def invalidate(self) -> None:
|
||||
return None
|
||||
|
||||
async def clear(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
Vendored
+5
@@ -99,6 +99,11 @@ class RAMIndexCacheStore(IndexCacheStore, KeyLockMixin):
|
||||
self._expiry.pop(resource_path, None)
|
||||
self._children.pop(resource_path, None)
|
||||
|
||||
async def invalidate(self) -> None:
|
||||
past = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
for key in list(self._expiry):
|
||||
self._expiry[key] = past
|
||||
|
||||
async def clear(self) -> None:
|
||||
self._entries.clear()
|
||||
self._children.clear()
|
||||
|
||||
Vendored
+18
@@ -201,6 +201,24 @@ class RedisIndexCacheStore(IndexCacheStore):
|
||||
pipe.delete(children_key)
|
||||
await pipe.execute()
|
||||
|
||||
async def invalidate(self) -> None:
|
||||
"""Clear rather than expire, because redis cannot say "stale" here.
|
||||
|
||||
The RAM store marks entries expired in place, so a later lookup
|
||||
answers EXPIRED and a backend whose index *is* its listing knows to
|
||||
refetch. A redis key carries a real TTL and an expired one is
|
||||
simply gone, so absent and stale read the same and this can only
|
||||
clear. The consequence, deliberately chosen: a github mount on a
|
||||
redis index answers ENOENT after a CLI write instead of refetching
|
||||
(``ls`` reports the mount root missing). That is a loud failure,
|
||||
not a wrong answer -- a no-op here would instead serve the
|
||||
pre-write tree as if it were current, and quietly wrong is the
|
||||
worse of the two. Closing this properly means an ``invalidated_at``
|
||||
marker key compared against each entry's ``index_time``, which
|
||||
needs no schema change and can ride the same round trip.
|
||||
"""
|
||||
await self.clear()
|
||||
|
||||
async def clear(self) -> None:
|
||||
self._pending_seed = None
|
||||
cursor = 0
|
||||
|
||||
Vendored
+12
@@ -56,6 +56,18 @@ class IndexCacheStore:
|
||||
async def invalidate_dir(self, resource_path: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def invalidate(self) -> None:
|
||||
"""Mark every entry stale without discarding it.
|
||||
|
||||
The difference from ``clear`` is what a later lookup can tell.
|
||||
``clear`` leaves an empty store, which reads exactly like a store
|
||||
that was never filled, so a backend whose index *is* its listing
|
||||
cannot tell an invalidation from an empty repository. Expiring
|
||||
instead keeps that distinction: the lookup answers EXPIRED and
|
||||
the backend knows to refetch.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def clear(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# ========= 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. =========
|
||||
|
||||
from mirage.commands.cli.builtin.gh.api import api
|
||||
from mirage.commands.cli.builtin.gh.repo import fork, rename, view
|
||||
from mirage.commands.cli.types import CLISpec
|
||||
from mirage.commands.spec.types import Operand, Option
|
||||
from mirage.core.github.config import GhConfig
|
||||
from mirage.types import ResourceName
|
||||
|
||||
REPO_HELP = "Select another repository, as [HOST/]OWNER/REPO"
|
||||
|
||||
# The GitHub CLI, spelled as cli.github.com spells it. The `github` mount is
|
||||
# the read half -- a repository is a tree, so listing and reading it is `ls`
|
||||
# and `cat` -- and this is the write half plus the account-level operations a
|
||||
# filesystem has no shape for: forking, renaming, and `api` for everything
|
||||
# else. Install with a GhConfig; `repo` supplies the default repository that
|
||||
# real gh reads off the current git remote.
|
||||
GH = CLISpec(
|
||||
name="gh",
|
||||
description="GitHub CLI",
|
||||
config_model=GhConfig,
|
||||
# A write here lands on the same repository a `github` mount reads, and
|
||||
# it lands by name rather than by any vfs path, so the mount cannot
|
||||
# invalidate itself: without this, `gh api -X PUT .../contents/f`
|
||||
# followed by `cat /repo/f` serves the pre-write bytes.
|
||||
serves=(ResourceName.GITHUB, ),
|
||||
subcommands=(
|
||||
CLISpec(
|
||||
name="repo",
|
||||
description="Manage repositories",
|
||||
subcommands=(
|
||||
CLISpec(
|
||||
name="view",
|
||||
description="View a repository",
|
||||
fn=view,
|
||||
positional=(Operand(type="str", name="REPOSITORY"), ),
|
||||
),
|
||||
CLISpec(
|
||||
name="fork",
|
||||
description="Create a fork of a repository",
|
||||
fn=fork,
|
||||
write=True,
|
||||
positional=(Operand(type="str", name="REPOSITORY"), ),
|
||||
options=(Option(
|
||||
long="--fork-name",
|
||||
type="str",
|
||||
description="Rename the forked repository",
|
||||
), ),
|
||||
),
|
||||
CLISpec(
|
||||
name="rename",
|
||||
description="Rename a repository",
|
||||
fn=rename,
|
||||
write=True,
|
||||
positional=(Operand(
|
||||
type="str",
|
||||
name="NEW-NAME",
|
||||
required=True,
|
||||
), ),
|
||||
options=(Option(
|
||||
short="-R",
|
||||
long="--repo",
|
||||
type="str",
|
||||
description=REPO_HELP,
|
||||
), ),
|
||||
),
|
||||
),
|
||||
),
|
||||
CLISpec(
|
||||
name="api",
|
||||
description="Make an authenticated GitHub API request",
|
||||
fn=api,
|
||||
write=True,
|
||||
positional=(Operand(type="str", name="ENDPOINT", required=True), ),
|
||||
options=(
|
||||
Option(
|
||||
short="-X",
|
||||
long="--method",
|
||||
type="str",
|
||||
description="The HTTP method for the request",
|
||||
),
|
||||
Option(
|
||||
short="-f",
|
||||
long="--raw-field",
|
||||
type="str",
|
||||
multiple=True,
|
||||
description="Add a string parameter in key=value format",
|
||||
),
|
||||
Option(
|
||||
short="-F",
|
||||
long="--field",
|
||||
type="str",
|
||||
multiple=True,
|
||||
description="Add a typed parameter in key=value format",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
# ========= 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 json
|
||||
|
||||
from mirage.core.github.config import GhConfig
|
||||
from mirage.core.github.repo import RepoRef, parse_repo
|
||||
from mirage.io.stream import yield_bytes
|
||||
from mirage.io.types import ByteSource, IOResult
|
||||
from mirage.types import JsonValue
|
||||
|
||||
|
||||
def gh_repo(config: GhConfig, spec: str | None) -> RepoRef:
|
||||
"""The repository a line is about.
|
||||
|
||||
The operand when it named one, the install's own otherwise. Real gh
|
||||
resolves this from the current git remote, which a workspace has no
|
||||
equivalent of, so the config carries it.
|
||||
|
||||
Args:
|
||||
config (GhConfig): the install's configuration.
|
||||
spec (str | None): the repository the line named, if any.
|
||||
|
||||
Returns:
|
||||
RepoRef: the owner and repository names.
|
||||
|
||||
Raises:
|
||||
ValueError: neither the line nor the install named one.
|
||||
"""
|
||||
named = spec if spec else config.repo
|
||||
if not named:
|
||||
raise ValueError(
|
||||
"no repository given; pass one or set `repo` on the install")
|
||||
return parse_repo(named)
|
||||
|
||||
|
||||
def json_out(
|
||||
value: JsonValue,
|
||||
mutated: bool | None = None) -> tuple[ByteSource | None, IOResult]:
|
||||
text = "" if value is None else f"{json.dumps(value, indent=2)}\n"
|
||||
return yield_bytes(text.encode()), IOResult(mutated=mutated)
|
||||
|
||||
|
||||
def text_out(text: str) -> tuple[ByteSource | None, IOResult]:
|
||||
return yield_bytes(text.encode()), IOResult()
|
||||
@@ -0,0 +1,131 @@
|
||||
# ========= 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 re
|
||||
|
||||
from mirage.commands.cli.builtin.gh.accessor import json_out
|
||||
from mirage.commands.cli.types import CLIInvocation
|
||||
from mirage.commands.spec.types import FlagView
|
||||
from mirage.core.github._client import github_request
|
||||
from mirage.core.github.config import GhConfig
|
||||
from mirage.core.github.placeholder import expand
|
||||
from mirage.io.types import ByteSource, IOResult
|
||||
from mirage.types import JsonValue
|
||||
|
||||
INT_RE = re.compile(r"^-?\d+$")
|
||||
READ_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
|
||||
def typed(value: str) -> JsonValue:
|
||||
"""Read a `-F` value as the JSON type it spells.
|
||||
|
||||
`-f` is always a string; `-F` reads `true`, `false`, `null` and integers
|
||||
as their JSON types, which is gh's own split between `--raw-field` and
|
||||
`--field`.
|
||||
|
||||
Args:
|
||||
value (str): the value as typed.
|
||||
|
||||
Returns:
|
||||
JsonValue: the value as its JSON type.
|
||||
"""
|
||||
if value == "true":
|
||||
return True
|
||||
if value == "false":
|
||||
return False
|
||||
if value == "null":
|
||||
return None
|
||||
if INT_RE.match(value):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def split(pair: str) -> tuple[str, str]:
|
||||
"""Split one `key=value`, keeping every `=` after the first.
|
||||
|
||||
Args:
|
||||
pair (str): the field as typed.
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: the key and the value.
|
||||
|
||||
Raises:
|
||||
ValueError: the field holds no `=`.
|
||||
"""
|
||||
key, sep, value = pair.partition("=")
|
||||
if not sep:
|
||||
raise ValueError(f'expected "key=value", got "{pair}"')
|
||||
return key, value
|
||||
|
||||
|
||||
async def api(
|
||||
inv: CLIInvocation[GhConfig]) -> tuple[ByteSource | None, IOResult]:
|
||||
fl = FlagView(inv.flags)
|
||||
endpoint = inv.texts[0] if inv.texts else ""
|
||||
if not endpoint:
|
||||
raise ValueError("an API endpoint is required")
|
||||
fields: dict[str, JsonValue] = {}
|
||||
for pair in fl.as_list("raw_field"):
|
||||
key, value = split(pair)
|
||||
fields[key] = value
|
||||
for pair in fl.as_list("field"):
|
||||
key, value = split(pair)
|
||||
# gh expands a placeholder in a `-F` value but not in a `-f` one,
|
||||
# which its own --help spells out and a live 2.85 confirms.
|
||||
fields[key] = typed(expand(value, inv.config))
|
||||
# gh sends a body-bearing call as POST unless --method says otherwise,
|
||||
# and a bare one as GET.
|
||||
method = fl.as_str("method") or ("POST" if fields else "GET")
|
||||
endpoint = expand(endpoint, inv.config)
|
||||
path = endpoint if endpoint.startswith("/") else f"/{endpoint}"
|
||||
upper = method.upper()
|
||||
# `api` is one leaf for both halves of the API, so whether it wrote is
|
||||
# on the line rather than in the spec: a GET must not expire every
|
||||
# github mount the install serves.
|
||||
mutated = upper not in READ_METHODS
|
||||
# A GET carries its fields in the query string, everything else in a
|
||||
# JSON body; with no fields there is neither, so a bare DELETE has no
|
||||
# body.
|
||||
if upper == "GET":
|
||||
params = {k: _query(v) for k, v in fields.items()}
|
||||
result = await github_request(inv.config.token,
|
||||
upper,
|
||||
path,
|
||||
params=params or None,
|
||||
base_url=inv.config.base_url)
|
||||
else:
|
||||
result = await github_request(inv.config.token,
|
||||
upper,
|
||||
path,
|
||||
fields or None,
|
||||
base_url=inv.config.base_url)
|
||||
return json_out(result, mutated)
|
||||
|
||||
|
||||
def _query(value: JsonValue) -> str:
|
||||
"""One field as a query-string value.
|
||||
|
||||
Args:
|
||||
value (JsonValue): the field value.
|
||||
|
||||
Returns:
|
||||
str: its query-string spelling.
|
||||
"""
|
||||
if value is True:
|
||||
return "true"
|
||||
if value is False:
|
||||
return "false"
|
||||
if value is None:
|
||||
return "null"
|
||||
return str(value)
|
||||
@@ -0,0 +1,84 @@
|
||||
# ========= 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. =========
|
||||
|
||||
from mirage.commands.cli.builtin.gh.accessor import gh_repo, text_out
|
||||
from mirage.commands.cli.types import CLIInvocation
|
||||
from mirage.commands.spec.types import FlagView
|
||||
from mirage.core.github.config import GhConfig
|
||||
from mirage.core.github.repo import (fork_repo, login, read_readme,
|
||||
rename_repo, view_repo)
|
||||
from mirage.io.types import ByteSource, IOResult
|
||||
from mirage.types import JsonValue
|
||||
|
||||
|
||||
def summary(repo: JsonValue, readme: str | None) -> str:
|
||||
"""gh's own text view of a repository.
|
||||
|
||||
Two tab-separated header lines and then the README verbatim, with the
|
||||
`--` separator omitted entirely when there is no README. Probed
|
||||
against gh 2.85, whose description line is present and empty for a
|
||||
repository that has none.
|
||||
|
||||
Args:
|
||||
repo (JsonValue): the REST repository object.
|
||||
readme (str | None): the decoded README, None when absent.
|
||||
|
||||
Returns:
|
||||
str: what gh prints.
|
||||
"""
|
||||
fields = repo if isinstance(repo, dict) else {}
|
||||
name = fields.get("full_name")
|
||||
description = fields.get("description")
|
||||
head = (f"name:\t{name if isinstance(name, str) else ''}\n"
|
||||
f"description:\t"
|
||||
f"{description if isinstance(description, str) else ''}\n")
|
||||
if readme is None:
|
||||
return head
|
||||
return f"{head}--\n{readme}"
|
||||
|
||||
|
||||
async def view(
|
||||
inv: CLIInvocation[GhConfig]) -> tuple[ByteSource | None, IOResult]:
|
||||
operand = inv.texts[0] if inv.texts else None
|
||||
ref = gh_repo(inv.config, operand)
|
||||
repo = await view_repo(inv.config, ref)
|
||||
return text_out(summary(repo, await read_readme(inv.config, ref)))
|
||||
|
||||
|
||||
async def fork(
|
||||
inv: CLIInvocation[GhConfig]) -> tuple[ByteSource | None, IOResult]:
|
||||
fl = FlagView(inv.flags)
|
||||
operand = inv.texts[0] if inv.texts else None
|
||||
source = gh_repo(inv.config, operand)
|
||||
name = fl.as_str("fork_name")
|
||||
forked = await fork_repo(inv.config, source, name)
|
||||
landed = forked.get("full_name") if isinstance(forked, dict) else None
|
||||
full = landed if isinstance(
|
||||
landed, str) else (f"{await login(inv.config)}/{name or source.repo}")
|
||||
return text_out(f"✓ Created fork {full}\n")
|
||||
|
||||
|
||||
async def rename(
|
||||
inv: CLIInvocation[GhConfig]) -> tuple[ByteSource | None, IOResult]:
|
||||
fl = FlagView(inv.flags)
|
||||
# gh takes the *new name* as the operand and the repository to rename as
|
||||
# -R, which is the reverse of what the shape of the line suggests.
|
||||
target = gh_repo(inv.config, fl.as_str("repo"))
|
||||
name = inv.texts[0] if inv.texts else ""
|
||||
if not name:
|
||||
raise ValueError("a new repository name is required")
|
||||
renamed = await rename_repo(inv.config, target, name)
|
||||
landed = renamed.get("full_name") if isinstance(renamed, dict) else None
|
||||
full = landed if isinstance(landed, str) else name
|
||||
return text_out(f"✓ Renamed repository {full}\n")
|
||||
@@ -30,6 +30,7 @@ CLI_SPECS: dict[str, CLISpec] = {}
|
||||
# they must not import until the name is actually requested.
|
||||
BUILTIN_CLI_SPECS: dict[str, str] = {
|
||||
"discord": "mirage.commands.cli.builtin.discord:DISCORD",
|
||||
"gh": "mirage.commands.cli.builtin.gh:GH",
|
||||
"git": "mirage.commands.cli.builtin.git:GIT",
|
||||
"gws": "mirage.commands.cli.builtin.gws:GWS",
|
||||
"himalaya": "mirage.commands.cli.builtin.himalaya:HIMALAYA",
|
||||
|
||||
@@ -82,6 +82,26 @@ def flag_rows(spec: CommandSpec) -> list[tuple[str, str]]:
|
||||
return [(_flag_display(o), o.description or "") for o in spec.options]
|
||||
|
||||
|
||||
def _slot(operand: Operand) -> str:
|
||||
"""One operand's placeholder outside the clap dialect.
|
||||
|
||||
A spec that named the slot gets that name, which is what argparse
|
||||
prints too (it renders the dest, not a generic word); a spec that did
|
||||
not falls back to the type. Only `gh` names one outside clap today, so
|
||||
this is the difference between `gh api [flags] <text>` and upstream's
|
||||
`gh api <endpoint>`.
|
||||
|
||||
Args:
|
||||
operand (Operand): the slot to render.
|
||||
|
||||
Returns:
|
||||
str: the bracketed placeholder.
|
||||
"""
|
||||
if operand.name:
|
||||
return f"<{operand.name}>"
|
||||
return "<path>" if operand.type == "path" else "<text>"
|
||||
|
||||
|
||||
def usage_line(name: str, spec: CommandSpec, subcommands: SubcommandRows,
|
||||
style: UsageStyle) -> str:
|
||||
"""The ``Usage:`` line, in the dialect the CLI declares.
|
||||
@@ -107,14 +127,12 @@ def usage_line(name: str, spec: CommandSpec, subcommands: SubcommandRows,
|
||||
if clap:
|
||||
bits.append(operand_slot(operand))
|
||||
else:
|
||||
bits.append("<path>" if operand.type == "path" else "<text>")
|
||||
bits.append(_slot(operand))
|
||||
if spec.rest is not None:
|
||||
if clap:
|
||||
bits.append(operand_slot(spec.rest, ellipsis=True))
|
||||
elif spec.rest.type == "path":
|
||||
bits.append("[<path>...]")
|
||||
else:
|
||||
bits.append("[<text>...]")
|
||||
bits.append(f"[{_slot(spec.rest)}...]")
|
||||
return "Usage: " + " ".join(bits)
|
||||
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from pydantic import SecretStr
|
||||
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
from mirage.types import JsonValue
|
||||
|
||||
API_BASE = "https://api.github.com"
|
||||
API_VERSION = "2022-11-28"
|
||||
@@ -35,6 +37,19 @@ def github_url(path: str, base_url: str | None = None, **kwargs: str) -> str:
|
||||
return (base_url or API_BASE) + path.format(**kwargs)
|
||||
|
||||
|
||||
class GitHubApiError(Exception):
|
||||
"""A GitHub call that answered with a status the caller cannot use.
|
||||
|
||||
Args:
|
||||
message (str): what GitHub said, its own wording where it gave one.
|
||||
status (int): the HTTP status.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, status: int) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
async def github_get(token: SecretStr,
|
||||
path: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
@@ -47,3 +62,70 @@ async def github_get(token: SecretStr,
|
||||
async with session.get(url, headers=headers, params=params) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
|
||||
async def github_request(token: SecretStr,
|
||||
method: str,
|
||||
path: str,
|
||||
body: "JsonValue | None" = None,
|
||||
params: dict[str, str] | None = None,
|
||||
*,
|
||||
base_url: str | None = None) -> "JsonValue":
|
||||
"""One arbitrary API call, the shape `gh api` needs.
|
||||
|
||||
A GET carries its fields in the query string and every other method in
|
||||
a JSON body, which is gh's own rule; a call with neither sends no body
|
||||
at all, so a bare DELETE stays a bare DELETE rather than an empty JSON
|
||||
object. The path is used verbatim, never format-expanded, because it
|
||||
arrives from a command line and may hold braces of its own.
|
||||
|
||||
Args:
|
||||
token (SecretStr): the API token.
|
||||
method (str): the HTTP method.
|
||||
path (str): the endpoint path, leading slash included.
|
||||
body (JsonValue | None): the JSON body, None to send none.
|
||||
params (dict[str, str] | None): query parameters.
|
||||
base_url (str | None): API base, defaulting to github.com's.
|
||||
|
||||
Returns:
|
||||
JsonValue: the decoded body, None for an empty one.
|
||||
|
||||
Raises:
|
||||
GitHubApiError: the call answered with a non-2xx status.
|
||||
"""
|
||||
url = (base_url or API_BASE) + path
|
||||
headers = github_headers(token)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.request(method.upper(),
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=body) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status >= 400:
|
||||
raise GitHubApiError(_api_message(text, resp.reason),
|
||||
resp.status)
|
||||
# 204 and an empty 202 have no body to decode; the caller gets
|
||||
# None rather than a parse error on a call that worked.
|
||||
if not text:
|
||||
return None
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def _api_message(text: str, reason: str | None) -> str:
|
||||
"""GitHub's own wording for a failure, or the status reason.
|
||||
|
||||
Args:
|
||||
text (str): the response body.
|
||||
reason (str | None): the HTTP reason phrase.
|
||||
|
||||
Returns:
|
||||
str: the message to report.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except ValueError:
|
||||
return reason or text
|
||||
if isinstance(payload, dict) and isinstance(payload.get("message"), str):
|
||||
return payload["message"]
|
||||
return reason or text
|
||||
|
||||
@@ -21,3 +21,18 @@ class GitHubConfig(BaseModel):
|
||||
repo: str | None = None
|
||||
ref: str = "main"
|
||||
base_url: str | None = None
|
||||
|
||||
|
||||
class GhConfig(BaseModel):
|
||||
"""What a `gh` install is configured with.
|
||||
|
||||
`repo` is what real gh reads off the current git remote to answer a line
|
||||
that names no repository; a workspace has no remote, so the install
|
||||
carries it. `branch` stands in the same way for the checked-out branch,
|
||||
which is what `{branch}` expands to in an endpoint.
|
||||
"""
|
||||
|
||||
token: SecretStr
|
||||
base_url: str | None = None
|
||||
repo: str | None = None
|
||||
branch: str | None = None
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# ========= 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 re
|
||||
|
||||
from mirage.core.github.config import GhConfig
|
||||
from mirage.core.github.repo import parse_repo
|
||||
|
||||
PLACEHOLDER_RE = re.compile(r"\{(owner|repo|branch)\}")
|
||||
EXPAND_ERROR = "unable to expand placeholder in path"
|
||||
|
||||
|
||||
def _value(name: str, config: GhConfig) -> str:
|
||||
"""One placeholder's expansion from the install.
|
||||
|
||||
Args:
|
||||
name (str): the placeholder word, without braces.
|
||||
config (GhConfig): the install's configuration.
|
||||
|
||||
Returns:
|
||||
str: what the placeholder stands for.
|
||||
|
||||
Raises:
|
||||
ValueError: the install carries nothing to expand it to.
|
||||
"""
|
||||
if name == "branch":
|
||||
if not config.branch:
|
||||
raise ValueError(f"{EXPAND_ERROR}: no `branch` on the install")
|
||||
return config.branch
|
||||
if not config.repo:
|
||||
raise ValueError(f"{EXPAND_ERROR}: no `repo` on the install")
|
||||
ref = parse_repo(config.repo)
|
||||
return ref.owner if name == "owner" else ref.repo
|
||||
|
||||
|
||||
def expand(text: str, config: GhConfig) -> str:
|
||||
"""Expand gh's repository placeholders in an endpoint or field value.
|
||||
|
||||
Real gh fills `{owner}`, `{repo}` and `{branch}` from the repository of
|
||||
the current directory, which is what most of its own documented
|
||||
examples are written with (`gh api repos/{owner}/{repo}/releases`); an
|
||||
install's `repo`/`branch` are the workspace's stand-in for that. Any
|
||||
other brace pair is left exactly as typed and reaches the wire, which
|
||||
is gh's behavior too: it is a path segment, not a template error.
|
||||
|
||||
Args:
|
||||
text (str): the endpoint path or field value as typed.
|
||||
config (GhConfig): the install's configuration.
|
||||
|
||||
Returns:
|
||||
str: the text with the three known placeholders expanded.
|
||||
|
||||
Raises:
|
||||
ValueError: a known placeholder has nothing to expand to.
|
||||
"""
|
||||
if "{" not in text:
|
||||
return text
|
||||
return PLACEHOLDER_RE.sub(lambda m: _value(m.group(1), config), text)
|
||||
@@ -18,6 +18,7 @@ from mirage.accessor.github import GitHubAccessor
|
||||
from mirage.cache.index import NULL_INDEX, IndexCacheStore, LookupStatus
|
||||
from mirage.core.github._client import github_get
|
||||
from mirage.core.github.config import GitHubConfig
|
||||
from mirage.core.github.tree import refill_index
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.errors import enoent
|
||||
|
||||
@@ -44,6 +45,19 @@ async def read(
|
||||
path = path_spec.mount_path
|
||||
|
||||
key = "/" + path.strip("/")
|
||||
# Freshness is tracked per directory, never per entry, so a blob's row
|
||||
# is exactly as fresh as its parent's listing and `get` can never
|
||||
# report staleness of its own. The parent is therefore the probe:
|
||||
# after a write invalidated the index the row survives carrying the
|
||||
# *pre-write* blob sha, and reading it back served the old bytes. A
|
||||
# miss is not a probe either -- against a live index it is a real
|
||||
# absence, and refetching the whole tree on every ENOENT costs a
|
||||
# recursive-tree call per miss.
|
||||
if not accessor.truncated:
|
||||
cut = key.rfind("/")
|
||||
parent = key[:cut] if cut > 0 else "/"
|
||||
if (await index.list_dir(parent)).status == LookupStatus.EXPIRED:
|
||||
await refill_index(accessor, index)
|
||||
result = await index.get(key)
|
||||
if result.status == LookupStatus.NOT_FOUND or result.entry is None:
|
||||
raise enoent(virtual)
|
||||
|
||||
@@ -16,7 +16,7 @@ import logging
|
||||
|
||||
from mirage.cache.index import (NULL_INDEX, IndexCacheStore, IndexEntry,
|
||||
LookupStatus)
|
||||
from mirage.core.github.tree import fetch_dir_tree
|
||||
from mirage.core.github.tree import fetch_dir_tree, refill_index
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.errors import enoent
|
||||
from mirage.utils.key_prefix import mount_prefix_of
|
||||
@@ -36,6 +36,13 @@ async def readdir(accessor,
|
||||
path = rest or "/"
|
||||
path = path.rstrip("/") or "/"
|
||||
listing = await index.list_dir(path)
|
||||
# The index is the whole listing here, not a cache in front of one, so
|
||||
# an *expired* answer means the tree aged out, not that the path is
|
||||
# gone. Refetch once and ask again. A NOT_FOUND against a live index
|
||||
# is a real absence and must not cost a tree fetch.
|
||||
if listing.status == LookupStatus.EXPIRED and not accessor.truncated:
|
||||
if await refill_index(accessor, index):
|
||||
listing = await index.list_dir(path)
|
||||
if listing.entries is not None:
|
||||
if prefix and listing.entries and not listing.entries[0].startswith(
|
||||
prefix):
|
||||
|
||||
@@ -12,8 +12,19 @@
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.core.github._client import github_get
|
||||
from mirage.core.github.config import GitHubConfig
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
|
||||
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,
|
||||
@@ -24,3 +35,99 @@ async def fetch_default_branch(config: GitHubConfig, owner: str,
|
||||
owner=owner,
|
||||
repo=repo)
|
||||
return data["default_branch"]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from mirage.cache.index import NULL_INDEX, IndexCacheStore, IndexEntry
|
||||
from mirage.core.github._client import github_get
|
||||
from mirage.core.github.config import GitHubConfig
|
||||
from mirage.core.github.tree_entry import TreeEntry
|
||||
@@ -95,3 +98,79 @@ async def fetch_dir_tree(
|
||||
size=item.get("size"),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def index_rows(
|
||||
tree: dict[str, TreeEntry]
|
||||
) -> tuple[dict[str, IndexEntry], dict[str, list[str]]]:
|
||||
"""Turn a git tree into the index's entry and children tables.
|
||||
|
||||
Shared so the mount's initial seed and a later refill build the same
|
||||
rows; TypeScript keeps its twin in this module too (`populateIndex`).
|
||||
|
||||
Args:
|
||||
tree (dict[str, TreeEntry]): the recursive tree, keyed by
|
||||
repo-relative path.
|
||||
|
||||
Returns:
|
||||
tuple[dict[str, IndexEntry], dict[str, list[str]]]: entries keyed
|
||||
by absolute path, and each directory's sorted children.
|
||||
"""
|
||||
dirs: dict[str, list[tuple[str, IndexEntry]]] = defaultdict(list)
|
||||
for path, entry in tree.items():
|
||||
parts = path.rsplit("/", 1)
|
||||
if len(parts) == 2:
|
||||
parent, name = "/" + parts[0], parts[1]
|
||||
else:
|
||||
parent, name = "/", parts[0]
|
||||
dirs[parent].append(
|
||||
(name,
|
||||
IndexEntry(
|
||||
id=entry.sha,
|
||||
name=name,
|
||||
resource_type=("folder" if entry.type == "tree" else "file"),
|
||||
size=entry.size,
|
||||
)))
|
||||
entries = {
|
||||
("/" + parent.strip("/") + "/" + name).replace("//", "/"): entry
|
||||
for parent, rows in dirs.items()
|
||||
for name, entry in rows
|
||||
}
|
||||
children = {
|
||||
parent:
|
||||
sorted(("/" + parent.strip("/") + "/" + name).replace("//", "/")
|
||||
for name, _ in rows)
|
||||
for parent, rows in dirs.items()
|
||||
}
|
||||
return entries, children
|
||||
|
||||
|
||||
async def refill_index(accessor, index: IndexCacheStore) -> bool:
|
||||
"""Refetch the recursive tree and re-seed the index from it.
|
||||
|
||||
The mount fetches the whole tree once and seeds the index with it, so
|
||||
the index is the listing rather than a cache in front of one. That
|
||||
makes a cleared or expired index indistinguishable from an empty
|
||||
repository -- `ls` reported the mount root missing after an
|
||||
invalidation, and reported nothing at all once the day-long TTL
|
||||
lapsed. This is the refill that makes dropping the index mean
|
||||
"refetch", which is what invalidating it was always supposed to mean.
|
||||
|
||||
Args:
|
||||
accessor (GitHubAccessor): the mount's accessor, holding the
|
||||
config and the ref to refetch.
|
||||
index (IndexCacheStore): the index to re-seed.
|
||||
|
||||
Returns:
|
||||
bool: whether a refill happened; False when there is no index to
|
||||
seed, so a caller does not retry a lookup that cannot change.
|
||||
"""
|
||||
if index is NULL_INDEX:
|
||||
return False
|
||||
tree, truncated = await fetch_tree(accessor.config, accessor.owner,
|
||||
accessor.repo, accessor.ref)
|
||||
accessor.truncated = truncated
|
||||
entries, children = index_rows(tree)
|
||||
index.seed(entries, children,
|
||||
datetime.now(timezone.utc) + timedelta(days=365))
|
||||
return True
|
||||
|
||||
@@ -93,6 +93,12 @@ class IOResult:
|
||||
reads (dict[str, ByteSource]): Paths read with content or streams.
|
||||
writes (dict[str, ByteSource]): Paths written with content or streams.
|
||||
cache (list[str]): Paths worth caching (from reads or writes).
|
||||
mutated (bool | None): whether this run changed service state,
|
||||
when only the handler can tell. A CLI leaf declares
|
||||
``write`` statically because for almost every verb it is
|
||||
static, but ``gh api`` carries its method on the line, so a
|
||||
plain ``gh api /user`` is a read through a leaf that is
|
||||
declared writable. None leaves the spec's answer standing.
|
||||
producer (Producer | None): provenance of this result (which
|
||||
command, spanning which mounts); merge keeps the rightmost
|
||||
producer, mirroring whose stream the shell shows. The
|
||||
@@ -126,6 +132,7 @@ class IOResult:
|
||||
writes: dict[str, ByteSource] = field(default_factory=dict)
|
||||
cache: list[str] = field(default_factory=list)
|
||||
producer: Producer | None = None
|
||||
mutated: bool | None = None
|
||||
_stream_source: "IOResult | None" = field(default=None, repr=False)
|
||||
|
||||
def __setattr__(self, name: str, value: object) -> None:
|
||||
|
||||
@@ -12,16 +12,15 @@
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from mirage.accessor.github import GitHubAccessor
|
||||
from mirage.cache.index import IndexConfig, IndexEntry
|
||||
from mirage.cache.index import IndexConfig
|
||||
from mirage.core.github.config import GitHubConfig
|
||||
from mirage.core.github.readdir import readdir
|
||||
from mirage.core.github.repo import fetch_default_branch
|
||||
from mirage.core.github.tree import fetch_tree
|
||||
from mirage.core.github.tree import fetch_tree, index_rows
|
||||
from mirage.core.github.tree_entry import TreeEntry
|
||||
from mirage.resource.base import BaseResource
|
||||
from mirage.resource.github.prompt import PROMPT
|
||||
@@ -140,32 +139,9 @@ class GitHubResource(BaseResource):
|
||||
truncated=truncated)
|
||||
|
||||
def _populate_index(self, tree: dict[str, TreeEntry]) -> None:
|
||||
dirs: dict[str, list[tuple[str, IndexEntry]]] = defaultdict(list)
|
||||
for path, entry in tree.items():
|
||||
parts = path.rsplit("/", 1)
|
||||
if len(parts) == 2:
|
||||
parent, name = "/" + parts[0], parts[1]
|
||||
else:
|
||||
parent, name = "/", parts[0]
|
||||
resource_type = "folder" if entry.type == "tree" else "file"
|
||||
idx_entry = IndexEntry(
|
||||
id=entry.sha,
|
||||
name=name,
|
||||
resource_type=resource_type,
|
||||
size=entry.size,
|
||||
)
|
||||
dirs[parent].append((name, idx_entry))
|
||||
self._github_index_entries = {
|
||||
("/" + parent.strip("/") + "/" + name).replace("//", "/"): entry
|
||||
for parent, entries in dirs.items()
|
||||
for name, entry in entries
|
||||
}
|
||||
self._github_index_children = {
|
||||
parent:
|
||||
sorted(("/" + parent.strip("/") + "/" + name).replace("//", "/")
|
||||
for name, _ in entries)
|
||||
for parent, entries in dirs.items()
|
||||
}
|
||||
entries, children = index_rows(tree)
|
||||
self._github_index_entries = entries
|
||||
self._github_index_children = children
|
||||
self._github_index_expiry = (datetime.now(timezone.utc) +
|
||||
timedelta(days=365))
|
||||
self._seed_github_index()
|
||||
|
||||
@@ -346,12 +346,16 @@ async def handle_cli(
|
||||
return None, err_io, ExecutionNode(command=cmd_str,
|
||||
exit_code=1,
|
||||
stderr=err_stderr)
|
||||
if leaf.write and drop_caches is not None:
|
||||
await drop_caches()
|
||||
if out is None:
|
||||
stdout, io = None, IOResult()
|
||||
else:
|
||||
stdout, io = out
|
||||
# The spec's `write` is the static answer, which is the only one most
|
||||
# verbs have; a handler that knows better says so on its result, so a
|
||||
# read-only `gh api` does not expire every github mount.
|
||||
mutated = leaf.write if io.mutated is None else io.mutated
|
||||
if mutated and drop_caches is not None:
|
||||
await drop_caches()
|
||||
io.producer = Producer(command=prog, declared=leaf.limit)
|
||||
|
||||
if parsed.warnings:
|
||||
|
||||
@@ -241,7 +241,12 @@ async def drop_service_caches(registry: MountRegistry,
|
||||
for mount in registry.mounts():
|
||||
if mount.resource.name not in wanted:
|
||||
continue
|
||||
await mount.resource.index.clear()
|
||||
# Invalidate rather than clear: a cleared index reads exactly like
|
||||
# one that was never filled, so a backend whose index *is* its
|
||||
# listing (github seeds the whole tree once) cannot tell the drop
|
||||
# from an empty repository and reports the mount as gone. Expiring
|
||||
# keeps that distinction and the next read refetches.
|
||||
await mount.resource.index.invalidate()
|
||||
if mount.cache_manager is not None:
|
||||
await mount.cache_manager.drop_prefix()
|
||||
|
||||
|
||||
Vendored
+35
@@ -17,6 +17,7 @@ from datetime import datetime
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import IndexEntry, RAMIndexCacheStore
|
||||
from mirage.cache.index.config import LookupStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -58,6 +59,40 @@ async def test_invalidate_dir_evicts_child_entries(store):
|
||||
assert "/dir" not in store._expiry
|
||||
|
||||
|
||||
# The distinction the whole github invalidation rests on: `clear` leaves a
|
||||
# store that reads exactly like one that was never filled, so a backend
|
||||
# whose index *is* its listing reports an empty repository. `invalidate`
|
||||
# keeps the rows and only expires them, so the lookup says EXPIRED and the
|
||||
# backend knows to refetch.
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_expires_without_discarding(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.set_dir("/dir", [("f.txt", entry)])
|
||||
await store.invalidate()
|
||||
assert (await store.list_dir("/dir")).status == LookupStatus.EXPIRED
|
||||
assert store._children["/dir"] == ["/dir/f.txt"]
|
||||
await store.clear()
|
||||
assert (await store.list_dir("/dir")).status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_expires_every_directory(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.set_dir("/a", [("f.txt", entry)])
|
||||
await store.set_dir("/b", [("g.txt", entry)])
|
||||
await store.invalidate()
|
||||
assert (await store.list_dir("/a")).status == LookupStatus.EXPIRED
|
||||
assert (await store.list_dir("/b")).status == LookupStatus.EXPIRED
|
||||
|
||||
|
||||
# An empty store has nothing to expire, and must not grow a row that makes
|
||||
# a never-listed directory answer EXPIRED instead of NOT_FOUND.
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_leaves_an_unlisted_directory_absent(store):
|
||||
await store.invalidate()
|
||||
assert (await store.list_dir("/dir")).status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_empties_all_dicts(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
# ========= 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 pytest
|
||||
|
||||
from mirage.commands.cli.builtin.gh import GH
|
||||
from mirage.commands.cli.builtin.gh.api import api
|
||||
from mirage.commands.cli.builtin.gh.repo import fork, rename, summary, view
|
||||
from mirage.commands.cli.specs import cli_spec_for
|
||||
from mirage.commands.cli.types import CLIInvocation
|
||||
from mirage.core.github.config import GhConfig
|
||||
from mirage.io.types import materialize
|
||||
from mirage.types import ResourceName
|
||||
|
||||
CONFIG = GhConfig(token="t")
|
||||
CALLS: list[dict] = []
|
||||
REPLY: dict = {}
|
||||
README: list[str | None] = [None]
|
||||
|
||||
|
||||
def _record(**call) -> dict:
|
||||
CALLS.append(call)
|
||||
return REPLY
|
||||
|
||||
|
||||
def _reset(reply=None) -> None:
|
||||
CALLS.clear()
|
||||
README[0] = None
|
||||
globals()["REPLY"] = {} if reply is None else reply
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch(monkeypatch):
|
||||
_reset()
|
||||
|
||||
async def fake_view(config, ref):
|
||||
return _record(method="GET", path=f"/repos/{ref.owner}/{ref.repo}")
|
||||
|
||||
async def fake_readme(config, ref):
|
||||
return README[0]
|
||||
|
||||
async def fake_fork(config, ref, name=None):
|
||||
return _record(method="POST",
|
||||
path=f"/repos/{ref.owner}/{ref.repo}/forks",
|
||||
body={} if name is None else {"name": name})
|
||||
|
||||
async def fake_rename(config, ref, name):
|
||||
return _record(method="PATCH",
|
||||
path=f"/repos/{ref.owner}/{ref.repo}",
|
||||
body={"name": name})
|
||||
|
||||
async def fake_request(token,
|
||||
method,
|
||||
path,
|
||||
body=None,
|
||||
params=None,
|
||||
*,
|
||||
base_url=None):
|
||||
call = {"method": method, "path": path}
|
||||
if body is not None:
|
||||
call["body"] = body
|
||||
if params is not None:
|
||||
call["params"] = params
|
||||
return _record(**call)
|
||||
|
||||
monkeypatch.setitem(view.__globals__, "view_repo", fake_view)
|
||||
monkeypatch.setitem(view.__globals__, "read_readme", fake_readme)
|
||||
monkeypatch.setitem(fork.__globals__, "fork_repo", fake_fork)
|
||||
monkeypatch.setitem(rename.__globals__, "rename_repo", fake_rename)
|
||||
monkeypatch.setitem(api.__globals__, "github_request", fake_request)
|
||||
|
||||
|
||||
def _inv(texts=(), flags=None, config=CONFIG) -> CLIInvocation:
|
||||
return CLIInvocation(config, texts=tuple(texts), flags=flags or {})
|
||||
|
||||
|
||||
def test_registers_itself_under_the_grammar_gh_uses():
|
||||
assert cli_spec_for("gh") is GH
|
||||
assert [c.name for c in GH.subcommands] == ["repo", "api"]
|
||||
assert [c.name for c in GH.subcommands[0].subcommands
|
||||
] == ["view", "fork", "rename"]
|
||||
|
||||
|
||||
# A gh write lands on the repository a `github` mount reads, by name rather
|
||||
# than by any vfs path, so the mount cannot invalidate itself. Without this
|
||||
# the executor's post-write cache drop is a no-op and a committed file still
|
||||
# reads back as its pre-write bytes.
|
||||
def test_names_the_mounted_resource_its_writes_invalidate():
|
||||
assert GH.serves == (ResourceName.GITHUB, )
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_views_the_repository_the_operand_names():
|
||||
await view(_inv(["o/r"]))
|
||||
assert CALLS == [{"method": "GET", "path": "/repos/o/r"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_the_install_repo():
|
||||
await view(_inv(config=GhConfig(token="t", repo="cfg/repo")))
|
||||
assert CALLS[0]["path"] == "/repos/cfg/repo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refuses_a_line_with_no_repository_anywhere():
|
||||
with pytest.raises(ValueError, match="no repository given"):
|
||||
await view(_inv())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refuses_a_repository_that_is_not_owner_repo():
|
||||
with pytest.raises(ValueError, match="OWNER/REPO"):
|
||||
await view(_inv(["justaname"]))
|
||||
|
||||
|
||||
# gh's format is [HOST/]OWNER/REPO, so the owner and repo are the *last* two
|
||||
# segments. Reading the first two made `github.com/acme/tools` a request for
|
||||
# `github.com/acme` -- a different repository, reported as success.
|
||||
@pytest.mark.asyncio
|
||||
async def test_drops_the_optional_host_rather_than_shifting_the_repo():
|
||||
await view(_inv(["github.com/acme/tools"]))
|
||||
assert CALLS[0]["path"] == "/repos/acme/tools"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refuses_more_segments_than_a_host_and_a_repository():
|
||||
with pytest.raises(ValueError, match="OWNER/REPO"):
|
||||
await view(_inv(["a/b/c/d"]))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_names_the_fork_at_creation_time():
|
||||
_reset({"full_name": "me/renamed"})
|
||||
out, _io = await fork(_inv(["o/r"], {"fork_name": "renamed"}))
|
||||
assert CALLS == [{
|
||||
"method": "POST",
|
||||
"path": "/repos/o/r/forks",
|
||||
"body": {
|
||||
"name": "renamed"
|
||||
}
|
||||
}]
|
||||
assert b"me/renamed" in await materialize(out)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forks_under_the_source_name_when_unnamed():
|
||||
_reset({"full_name": "me/r"})
|
||||
await fork(_inv(["o/r"]))
|
||||
assert CALLS[0]["body"] == {}
|
||||
|
||||
|
||||
# gh takes the new name as the operand and the repository to rename as -R,
|
||||
# which is the reverse of what the shape of the line suggests.
|
||||
@pytest.mark.asyncio
|
||||
async def test_renames_the_dash_r_repository_to_the_operand():
|
||||
_reset({"full_name": "me/after"})
|
||||
await rename(_inv(["after"], {"repo": "me/before"}))
|
||||
assert CALLS == [{
|
||||
"method": "PATCH",
|
||||
"path": "/repos/me/before",
|
||||
"body": {
|
||||
"name": "after"
|
||||
}
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_is_a_get_with_no_fields_and_sends_them_as_query():
|
||||
await api(
|
||||
_inv(["repos/o/r/contents/x"], {
|
||||
"raw_field": ["ref=master"],
|
||||
"method": "GET"
|
||||
}))
|
||||
assert CALLS[0] == {
|
||||
"method": "GET",
|
||||
"path": "/repos/o/r/contents/x",
|
||||
"params": {
|
||||
"ref": "master"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_is_a_post_once_a_field_is_given():
|
||||
await api(_inv(["repos/o/r/issues"], {"raw_field": ["title=hi"]}))
|
||||
assert CALLS[0]["method"] == "POST"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_sends_dash_f_verbatim_and_reads_dash_f_as_json_types():
|
||||
await api(
|
||||
_inv(
|
||||
["x"], {
|
||||
"method": "PUT",
|
||||
"raw_field": ["a=1"],
|
||||
"field": ["b=2", "c=true", "d=null", "e=text"],
|
||||
}))
|
||||
assert CALLS[0]["body"] == {
|
||||
"a": "1",
|
||||
"b": 2,
|
||||
"c": True,
|
||||
"d": None,
|
||||
"e": "text"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_keeps_everything_after_the_first_equals():
|
||||
await api(_inv(["x"], {"raw_field": ["content=YQ==\n"]}))
|
||||
assert CALLS[0]["body"] == {"content": "YQ==\n"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_takes_an_endpoint_with_or_without_a_leading_slash():
|
||||
await api(_inv(["/user"]))
|
||||
assert CALLS[0]["path"] == "/user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_refuses_a_field_that_is_not_key_value():
|
||||
with pytest.raises(ValueError, match="key=value"):
|
||||
await api(_inv(["x"], {"raw_field": ["nope"]}))
|
||||
|
||||
|
||||
# Real gh sends no body for a call carrying no fields, so a bare DELETE is a
|
||||
# bare DELETE rather than an empty JSON object with a content type.
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_sends_no_body_at_all_when_no_field_was_given():
|
||||
await api(_inv(["repos/o/r"], {"method": "DELETE"}))
|
||||
assert CALLS[0] == {"method": "DELETE", "path": "/repos/o/r"}
|
||||
|
||||
|
||||
# -F types a value for a JSON body; on a GET the same value has to reach the
|
||||
# query string, where everything is a string.
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_stringifies_a_typed_field_bound_for_the_query():
|
||||
await api(
|
||||
_inv(["search/code"], {
|
||||
"method": "GET",
|
||||
"field": ["per_page=5", "draft=true"]
|
||||
}))
|
||||
assert CALLS[0]["params"] == {"per_page": "5", "draft": "true"}
|
||||
assert "body" not in CALLS[0]
|
||||
|
||||
|
||||
# gh prints two tab-separated header lines and then the README verbatim;
|
||||
# with no README there is no `--` separator at all. Probed against 2.85.
|
||||
def test_summary_is_gh_s_two_headers_then_the_readme():
|
||||
out = summary({"full_name": "o/r", "description": "d"}, "# Title\n")
|
||||
assert out == "name:\to/r\ndescription:\td\n--\n# Title\n"
|
||||
|
||||
|
||||
def test_summary_omits_the_separator_without_a_readme():
|
||||
out = summary({"full_name": "o/r", "description": None}, None)
|
||||
assert out == "name:\to/r\ndescription:\t\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_renders_text_not_the_rest_object():
|
||||
_reset({"full_name": "integ/x", "description": "hi"})
|
||||
README[0] = "body\n"
|
||||
out, _io = await view(_inv(["integ/x"]))
|
||||
assert await materialize(
|
||||
out) == b"name:\tinteg/x\ndescription:\thi\n--\nbody\n"
|
||||
@@ -12,7 +12,12 @@
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.core.github._client import github_headers, github_url
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from aiohttp import web
|
||||
|
||||
from mirage.core.github._client import (GitHubApiError, github_headers,
|
||||
github_request, github_url)
|
||||
from mirage.core.github.config import GitHubConfig
|
||||
|
||||
|
||||
@@ -56,3 +61,92 @@ def test_config_base_url_defaults_to_none():
|
||||
def test_config_carries_base_url():
|
||||
config = GitHubConfig(token="ghp_test", base_url="http://localhost:1234")
|
||||
assert config.base_url == "http://localhost:1234"
|
||||
|
||||
|
||||
SEEN: list[dict] = []
|
||||
REPLY: dict = {"status": 200, "body": '{"ok":true}'}
|
||||
|
||||
|
||||
async def _echo(request: web.Request) -> web.Response:
|
||||
SEEN.append({
|
||||
"method": request.method,
|
||||
"path": request.path,
|
||||
"query": dict(request.query),
|
||||
"body": await request.text(),
|
||||
"content_type": request.headers.get("Content-Type"),
|
||||
})
|
||||
return web.Response(status=REPLY["status"],
|
||||
text=REPLY["body"],
|
||||
content_type="application/json")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def base_url():
|
||||
SEEN.clear()
|
||||
REPLY.update({"status": 200, "body": '{"ok":true}'})
|
||||
app = web.Application()
|
||||
app.router.add_route("*", "/{tail:.*}", _echo)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_puts_params_on_the_query_and_no_body_on_a_get(base_url):
|
||||
await github_request("t",
|
||||
"GET",
|
||||
"/repos/o/r/git/trees/main",
|
||||
params={"recursive": "1"},
|
||||
base_url=base_url)
|
||||
assert SEEN[0]["query"] == {"recursive": "1"}
|
||||
assert SEEN[0]["body"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_sends_a_body_as_json(base_url):
|
||||
await github_request("t",
|
||||
"PATCH",
|
||||
"/repos/o/r", {"name": "after"},
|
||||
base_url=base_url)
|
||||
assert SEEN[0]["method"] == "PATCH"
|
||||
assert SEEN[0]["body"] == '{"name": "after"}'
|
||||
assert "application/json" in (SEEN[0]["content_type"] or "")
|
||||
|
||||
|
||||
# Real gh sends nothing for a fieldless call; an empty JSON object plus a
|
||||
# content type is a different request, and some endpoints read it as one.
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_sends_no_body_when_there_is_nothing_to_send(base_url):
|
||||
await github_request("t", "DELETE", "/repos/o/r", base_url=base_url)
|
||||
assert SEEN[0]["body"] == ""
|
||||
assert SEEN[0]["content_type"] is None
|
||||
|
||||
|
||||
# The path arrives from a command line and is used verbatim: a brace in it
|
||||
# is a brace, never a format placeholder that eats the segment it sits in.
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_does_not_format_expand_the_path(base_url):
|
||||
await github_request("t",
|
||||
"GET",
|
||||
"/repos/o/r/contents/{tmpl}",
|
||||
base_url=base_url)
|
||||
assert SEEN[0]["path"] == "/repos/o/r/contents/{tmpl}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_decodes_an_empty_response_to_none(base_url):
|
||||
REPLY.update({"status": 204, "body": ""})
|
||||
assert await github_request("t", "DELETE", "/repos/o/r",
|
||||
base_url=base_url) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_raises_with_githubs_own_wording_and_status(base_url):
|
||||
REPLY.update({"status": 404, "body": '{"message":"Not Found"}'})
|
||||
with pytest.raises(GitHubApiError, match="Not Found") as excinfo:
|
||||
await github_request("t", "GET", "/repos/o/r", base_url=base_url)
|
||||
assert excinfo.value.status == 404
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# ========= 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 pytest
|
||||
|
||||
from mirage.core.github.config import GhConfig
|
||||
from mirage.core.github.placeholder import expand
|
||||
|
||||
CONFIG = GhConfig(token="t", repo="acme/tools", branch="main")
|
||||
|
||||
|
||||
def test_expands_the_three_gh_documents():
|
||||
assert expand("repos/{owner}/{repo}/branches/{branch}",
|
||||
CONFIG) == "repos/acme/tools/branches/main"
|
||||
|
||||
|
||||
def test_leaves_a_path_with_no_braces_untouched():
|
||||
assert expand("/user", CONFIG) == "/user"
|
||||
|
||||
|
||||
# gh leaves an unknown placeholder alone and lets the request go out; the
|
||||
# API answers 404 for the literal segment. Probed against gh 2.85, which
|
||||
# returns GitHub's own Not Found rather than a client-side error.
|
||||
def test_leaves_an_unknown_placeholder_on_the_wire():
|
||||
assert expand("repos/{owner}/{nope}", CONFIG) == "repos/acme/{nope}"
|
||||
|
||||
|
||||
def test_reads_the_owner_off_a_host_qualified_repo():
|
||||
config = GhConfig(token="t", repo="github.com/acme/tools")
|
||||
assert expand("repos/{owner}/{repo}", config) == "repos/acme/tools"
|
||||
|
||||
|
||||
def test_refuses_owner_when_the_install_names_no_repository():
|
||||
with pytest.raises(ValueError, match="unable to expand placeholder"):
|
||||
expand("repos/{owner}/x", GhConfig(token="t"))
|
||||
|
||||
|
||||
# `branch` is its own field: an install may name a repository without
|
||||
# pinning a branch, and gh reports the failure rather than guessing one.
|
||||
def test_refuses_branch_when_the_install_pins_none():
|
||||
config = GhConfig(token="t", repo="acme/tools")
|
||||
with pytest.raises(ValueError, match="no `branch` on the install"):
|
||||
expand("repos/{owner}/{repo}/branches/{branch}", config)
|
||||
@@ -13,12 +13,19 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import mirage.core.github.read
|
||||
import mirage.core.github.tree
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.github.config import GitHubConfig
|
||||
from mirage.core.github.read import read_bytes
|
||||
from mirage.core.github.read import read, read_bytes
|
||||
from mirage.core.github.tree_entry import TreeEntry
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -43,3 +50,66 @@ async def test_read_bytes_binary(mock_get, config):
|
||||
mock_get.return_value = {"content": base64.b64encode(content).decode()}
|
||||
result = await read_bytes(config, "acme", "proj", "sha456")
|
||||
assert result == content
|
||||
|
||||
|
||||
def _index() -> RAMIndexCacheStore:
|
||||
index = RAMIndexCacheStore()
|
||||
entry = IndexEntry(id="bbb", name="main.py", resource_type="file", size=3)
|
||||
index._entries["/src/main.py"] = entry
|
||||
index._children["/src"] = ["/src/main.py"]
|
||||
index._expiry["/src"] = datetime.now(timezone.utc) + timedelta(days=365)
|
||||
return index
|
||||
|
||||
|
||||
# Same rule readdir follows: an expired index is a tree that aged out, so
|
||||
# `cat` refetches once rather than reporting the file gone.
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_refills_an_expired_index(monkeypatch):
|
||||
index = _index()
|
||||
await index.invalidate()
|
||||
calls = []
|
||||
|
||||
async def fake_fetch_tree(config, owner, repo, ref):
|
||||
calls.append(ref)
|
||||
return {
|
||||
"src":
|
||||
TreeEntry(path="src", type="tree", sha="aaa", size=None),
|
||||
"src/main.py":
|
||||
TreeEntry(path="src/main.py", type="blob", sha="bbb", size=3),
|
||||
}, False
|
||||
|
||||
async def fake_read_bytes(config, owner, repo, sha):
|
||||
return b"hi\n"
|
||||
|
||||
monkeypatch.setattr(mirage.core.github.tree, "fetch_tree", fake_fetch_tree)
|
||||
monkeypatch.setattr(mirage.core.github.read, "read_bytes", fake_read_bytes)
|
||||
accessor = MagicMock()
|
||||
accessor.truncated = False
|
||||
out = await read(
|
||||
accessor,
|
||||
PathSpec(resource_path="src/main.py",
|
||||
virtual="/src/main.py",
|
||||
directory="/src"), index)
|
||||
assert out == b"hi\n"
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_does_not_refill_on_a_real_miss(monkeypatch):
|
||||
index = _index()
|
||||
calls = []
|
||||
|
||||
async def fake_fetch_tree(config, owner, repo, ref):
|
||||
calls.append(ref)
|
||||
return {}, False
|
||||
|
||||
monkeypatch.setattr(mirage.core.github.tree, "fetch_tree", fake_fetch_tree)
|
||||
accessor = MagicMock()
|
||||
accessor.truncated = False
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(
|
||||
accessor,
|
||||
PathSpec(resource_path="src/gone.py",
|
||||
virtual="/src/gone.py",
|
||||
directory="/src"), index)
|
||||
assert calls == []
|
||||
|
||||
@@ -18,6 +18,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import mirage.core.github.tree
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.github.readdir import readdir
|
||||
@@ -113,3 +114,52 @@ async def test_readdir_missing_directory(tree):
|
||||
PathSpec(resource_path="nonexistent",
|
||||
virtual="/nonexistent",
|
||||
directory="/nonexistent"), index)
|
||||
|
||||
|
||||
# The index *is* the listing here, seeded once from the recursive tree, so
|
||||
# an expired one is a tree that aged out rather than a repository that
|
||||
# emptied. Before the refill, `ls /repo` exited 0 with no output once the
|
||||
# day-long TTL lapsed, and reported the mount root missing after a `gh`
|
||||
# write invalidated it.
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_refills_an_expired_index(tree, monkeypatch):
|
||||
index = _index_from_tree(tree)
|
||||
await index.invalidate()
|
||||
calls = []
|
||||
|
||||
async def fake_fetch_tree(config, owner, repo, ref):
|
||||
calls.append((owner, repo, ref))
|
||||
return tree, False
|
||||
|
||||
monkeypatch.setattr(mirage.core.github.tree, "fetch_tree", fake_fetch_tree)
|
||||
accessor = MagicMock()
|
||||
accessor.truncated = False
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert result == ["/README.md", "/src"]
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
# A miss against a live index is a real absence, so it must stay one call
|
||||
# of nothing: refilling here would spend a full recursive-tree fetch on
|
||||
# every ENOENT.
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_does_not_refill_on_a_real_miss(tree, monkeypatch):
|
||||
index = _index_from_tree(tree)
|
||||
calls = []
|
||||
|
||||
async def fake_fetch_tree(config, owner, repo, ref):
|
||||
calls.append((owner, repo, ref))
|
||||
return tree, False
|
||||
|
||||
monkeypatch.setattr(mirage.core.github.tree, "fetch_tree", fake_fetch_tree)
|
||||
accessor = MagicMock()
|
||||
accessor.truncated = False
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="nonexistent",
|
||||
virtual="/nonexistent",
|
||||
directory="/nonexistent"), index)
|
||||
assert calls == []
|
||||
|
||||
@@ -17,7 +17,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from mirage.core.github.config import GitHubConfig
|
||||
from mirage.core.github.repo import fetch_default_branch
|
||||
from mirage.core.github.repo import fetch_default_branch, parse_repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -44,3 +44,23 @@ async def test_fetch_default_branch_master(mock_get, config):
|
||||
mock_get.return_value = {"default_branch": "master"}
|
||||
result = await fetch_default_branch(config, "acme", "proj")
|
||||
assert result == "master"
|
||||
|
||||
|
||||
# gh's format is [HOST/]OWNER/REPO, so the owner and repository are always the
|
||||
# last two segments. Taking the first two read `github.com/acme/tools` as
|
||||
# owner `github.com`, repo `acme` -- a different repository, reported as
|
||||
# success rather than as an error.
|
||||
def test_parse_repo_takes_owner_and_repo():
|
||||
ref = parse_repo("acme/tools")
|
||||
assert (ref.owner, ref.repo) == ("acme", "tools")
|
||||
|
||||
|
||||
def test_parse_repo_drops_the_optional_host():
|
||||
ref = parse_repo("github.com/acme/tools")
|
||||
assert (ref.owner, ref.repo) == ("acme", "tools")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", ["justaname", "a/b/c/d", "acme/", "/tools"])
|
||||
def test_parse_repo_refuses_a_spec_that_is_not_the_format(spec):
|
||||
with pytest.raises(ValueError, match="OWNER/REPO"):
|
||||
parse_repo(spec)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"baseline": 305,
|
||||
"baseline": 303,
|
||||
"baseline_reason": "Every divergence below the excused ones predates the gate and each needs its own decision, so --strict fails on a rise rather than demanding zero. Lower this number whenever a divergence is closed; the gate fails on a drop too, so an improvement cannot be silently spent. Items 31-37 of the cleanup plan are scoped from this report. The unit is one module, never one directory: a one-sided directory counts once per module inside it, so it cannot absorb new modules without moving the number. An excused directory is the deliberate exception -- it excuses its whole subtree, because the excuse is that there is no counterpart to mirror, which makes growth inside it expected rather than drift.",
|
||||
"directories": {
|
||||
"python_only": {
|
||||
|
||||
@@ -61,6 +61,10 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@octokit/core": "^7.0.7",
|
||||
"@octokit/plugin-retry": "^8.1.1",
|
||||
"@octokit/plugin-throttling": "^11.0.5",
|
||||
"@octokit/request-error": "^7.1.1",
|
||||
"bson": "^6.10.4",
|
||||
"isomorphic-git": "^1.38.1",
|
||||
"jq-wasm": "1.1.0-jq-1.8.1",
|
||||
|
||||
@@ -23,8 +23,10 @@ export class GitHubAccessor extends Accessor {
|
||||
readonly repo: string
|
||||
readonly ref: string
|
||||
readonly defaultBranch: string
|
||||
readonly truncated: boolean
|
||||
readonly tree: Record<string, TreeEntry>
|
||||
// Reseated by GitHubResource.refresh: the tree is the mount's whole
|
||||
// listing, so a refetch after a write replaces it wholesale.
|
||||
truncated: boolean
|
||||
tree: Record<string, TreeEntry>
|
||||
|
||||
constructor(opts: {
|
||||
transport: GitHubTransport
|
||||
|
||||
@@ -91,6 +91,30 @@ describe('RAMIndexCacheStore', () => {
|
||||
expect(get.entry ?? null).toBeNull()
|
||||
})
|
||||
|
||||
// The distinction the whole github invalidation rests on: `clear` leaves
|
||||
// a store that reads exactly like one that was never filled, so a backend
|
||||
// whose index *is* its listing reports an empty repository. `invalidate`
|
||||
// keeps the rows and only expires them, so the lookup says EXPIRED and
|
||||
// the backend knows to refetch.
|
||||
it('invalidate expires every directory without discarding it', async () => {
|
||||
const store = new RAMIndexCacheStore()
|
||||
await store.setDir('/a', [['x', mkEntry('1', 'x')]])
|
||||
await store.setDir('/b', [['y', mkEntry('2', 'y')]])
|
||||
await store.invalidate()
|
||||
expect((await store.listDir('/a')).status).toBe(LookupStatus.EXPIRED)
|
||||
expect((await store.listDir('/b')).status).toBe(LookupStatus.EXPIRED)
|
||||
await store.clear()
|
||||
expect((await store.listDir('/a')).status).toBe(LookupStatus.NOT_FOUND)
|
||||
})
|
||||
|
||||
// An empty store has nothing to expire, and must not grow a row that
|
||||
// makes a never-listed directory answer EXPIRED instead of NOT_FOUND.
|
||||
it('invalidate leaves an unlisted directory absent', async () => {
|
||||
const store = new RAMIndexCacheStore()
|
||||
await store.invalidate()
|
||||
expect((await store.listDir('/dir')).status).toBe(LookupStatus.NOT_FOUND)
|
||||
})
|
||||
|
||||
it('clear wipes everything', async () => {
|
||||
const store = new RAMIndexCacheStore()
|
||||
await store.put('/a', mkEntry('1', 'a'))
|
||||
|
||||
@@ -83,6 +83,12 @@ export class RAMIndexCacheStore extends IndexCacheStore {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
invalidate(): Promise<void> {
|
||||
const past = Date.now() - 1000
|
||||
for (const key of this.expiry.keys()) this.expiry.set(key, past)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
clear(): Promise<void> {
|
||||
this.entries.clear()
|
||||
this.children.clear()
|
||||
|
||||
@@ -170,6 +170,21 @@ export class RedisIndexCacheStore extends IndexCacheStore {
|
||||
await pipe.exec()
|
||||
}
|
||||
|
||||
// Clear rather than expire, because redis cannot say "stale" here. The RAM
|
||||
// store marks entries expired in place, so a later lookup answers EXPIRED
|
||||
// and a backend whose index *is* its listing knows to refetch. A redis key
|
||||
// carries a real TTL and an expired one is simply gone, so absent and stale
|
||||
// read the same. The consequence, deliberately chosen: a github mount on a
|
||||
// redis index answers ENOENT after a CLI write instead of refetching. That
|
||||
// is a loud failure, not a wrong answer -- a no-op here would instead serve
|
||||
// the pre-write tree as if it were current, and quietly wrong is the worse
|
||||
// of the two. Closing this properly means an `invalidatedAt` marker key
|
||||
// compared against each entry's indexTime, which needs no schema change and
|
||||
// can ride the same round trip.
|
||||
async invalidate(): Promise<void> {
|
||||
await this.clear()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const c = await this.client()
|
||||
for (const pattern of [`${this.entryPrefix}*`, `${this.childrenPrefix}*`]) {
|
||||
|
||||
@@ -24,6 +24,17 @@ export abstract class IndexCacheStore {
|
||||
expiredAt?: Date | null,
|
||||
): Promise<void>
|
||||
abstract invalidateDir(resourcePath: string): Promise<void>
|
||||
/**
|
||||
* Mark every entry stale without discarding it.
|
||||
*
|
||||
* The difference from `clear` is what a later lookup can tell. `clear`
|
||||
* leaves an empty store, which reads exactly like a store that was never
|
||||
* filled, so a backend whose index *is* its listing cannot tell an
|
||||
* invalidation from an empty repository. Expiring instead keeps that
|
||||
* distinction: the lookup answers EXPIRED and the backend refetches.
|
||||
*/
|
||||
abstract invalidate(): Promise<void>
|
||||
|
||||
abstract clear(): Promise<void>
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,9 @@ function makeAccessor(searchHits: string[], calls: SearchCall[]): GitHubAccessor
|
||||
}
|
||||
throw new Error(`unexpected transport call: ${path}`)
|
||||
},
|
||||
request(method: string, path: string): Promise<unknown> {
|
||||
throw new Error(`unexpected transport call: ${method} ${path}`)
|
||||
},
|
||||
}
|
||||
return new GitHubAccessor({
|
||||
transport,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// ========= 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 { HttpGitHubTransport, type GitHubTransport } from '../../../../core/github/_client.ts'
|
||||
import type { GhConfig } from '../../../../core/github/config.ts'
|
||||
import { parseRepo, type RepoRef } from '../../../../core/github/repo.ts'
|
||||
import { IOResult, type ByteSource } from '../../../../io/types.ts'
|
||||
import type { CommandFnResult } from '../../../config.ts'
|
||||
|
||||
const ENC = new TextEncoder()
|
||||
|
||||
export function ghTransport(config: unknown): GitHubTransport {
|
||||
const cfg = config as GhConfig
|
||||
const opts: { token: string; baseUrl?: string } = { token: cfg.token }
|
||||
if (cfg.baseUrl !== undefined) opts.baseUrl = cfg.baseUrl
|
||||
return new HttpGitHubTransport(opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository a line is about: the operand if it named one, the
|
||||
* install's own otherwise. gh resolves this from the current git remote,
|
||||
* which a workspace has no equivalent of, so the config carries it.
|
||||
*/
|
||||
export function ghRepo(config: unknown, spec: string | undefined): RepoRef {
|
||||
const named = spec ?? (config as GhConfig).repo
|
||||
if (named === undefined || named === '') {
|
||||
throw new Error('no repository given; pass one or set `repo` on the install')
|
||||
}
|
||||
return parseRepo(named)
|
||||
}
|
||||
|
||||
export function jsonOut(value: unknown, mutated?: boolean): CommandFnResult {
|
||||
const text = value === null ? '' : `${JSON.stringify(value, null, 2)}\n`
|
||||
const out: ByteSource = ENC.encode(text)
|
||||
return [out, new IOResult(mutated === undefined ? {} : { mutated })]
|
||||
}
|
||||
|
||||
export function textOut(text: string): CommandFnResult {
|
||||
const out: ByteSource = ENC.encode(text)
|
||||
return [out, new IOResult()]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// ========= 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 { FlagView } from '../../../spec/types.ts'
|
||||
import type { CommandFnResult } from '../../../config.ts'
|
||||
import type { CLIInvocation } from '../../types.ts'
|
||||
import { expand } from '../../../../core/github/placeholder.ts'
|
||||
import type { GhConfig } from '../../../../core/github/config.ts'
|
||||
import { ghTransport, jsonOut } from './accessor.ts'
|
||||
|
||||
/**
|
||||
* `-f key=value` is always a string; `-F key=value` reads `true`, `false`,
|
||||
* `null` and integers as their JSON types, which is gh's own split between
|
||||
* `--raw-field` and `--field`.
|
||||
*/
|
||||
function typed(value: string): string | number | boolean | null {
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
if (value === 'null') return null
|
||||
if (/^-?\d+$/.test(value)) return Number(value)
|
||||
return value
|
||||
}
|
||||
|
||||
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
||||
|
||||
function split(pair: string): [string, string] {
|
||||
const at = pair.indexOf('=')
|
||||
if (at < 0) throw new Error(`expected "key=value", got "${pair}"`)
|
||||
return [pair.slice(0, at), pair.slice(at + 1)]
|
||||
}
|
||||
|
||||
export async function api(inv: CLIInvocation): Promise<CommandFnResult> {
|
||||
const fl = new FlagView(inv.flags)
|
||||
const endpoint = inv.texts[0] ?? ''
|
||||
if (endpoint === '') throw new Error('an API endpoint is required')
|
||||
const fields: Record<string, string | number | boolean | null> = {}
|
||||
for (const pair of fl.asList('raw_field')) {
|
||||
const [key, value] = split(pair)
|
||||
fields[key] = value
|
||||
}
|
||||
const config = inv.config as GhConfig
|
||||
for (const pair of fl.asList('field')) {
|
||||
const [key, value] = split(pair)
|
||||
// gh expands a placeholder in a `-F` value but not in a `-f` one, which
|
||||
// its own --help spells out and a live 2.85 confirms.
|
||||
fields[key] = typed(expand(value, config))
|
||||
}
|
||||
// gh sends a body-bearing call as POST unless --method says otherwise,
|
||||
// and a bare one as GET.
|
||||
const method = fl.asStr('method') ?? (Object.keys(fields).length > 0 ? 'POST' : 'GET')
|
||||
const expanded = expand(endpoint, config)
|
||||
const path = expanded.startsWith('/') ? expanded : `/${expanded}`
|
||||
const upper = method.toUpperCase()
|
||||
// `api` is one leaf for both halves of the API, so whether it wrote is on
|
||||
// the line rather than in the spec: a GET must not expire every github
|
||||
// mount the install serves.
|
||||
const mutated = !READ_METHODS.has(upper)
|
||||
const empty = Object.keys(fields).length === 0
|
||||
// A GET carries its fields in the query string, everything else in a JSON
|
||||
// body; a call with no fields sends neither, so a bare DELETE has no body.
|
||||
if (upper === 'GET') {
|
||||
const params: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(fields)) params[key] = String(value)
|
||||
return jsonOut(await ghTransport(inv.config).request(upper, path, undefined, params), mutated)
|
||||
}
|
||||
return jsonOut(
|
||||
await ghTransport(inv.config).request(upper, path, empty ? undefined : fields),
|
||||
mutated,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// ========= 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 { describe, expect, it, vi } from 'vitest'
|
||||
import type * as AccessorModule from './accessor.ts'
|
||||
import type { GitHubTransport } from '../../../../core/github/_client.ts'
|
||||
import { cliSpecFor } from '../../specs.ts'
|
||||
import { ResourceName } from '../../../../types.ts'
|
||||
import type { CommandFnResult } from '../../../config.ts'
|
||||
import type { CLIInvocation } from '../../types.ts'
|
||||
import { GH } from './index.ts'
|
||||
import { api } from './api.ts'
|
||||
import { fork, rename, summary, view } from './repo.ts'
|
||||
|
||||
const DEC = new TextDecoder()
|
||||
|
||||
interface Call {
|
||||
method: string
|
||||
path: string
|
||||
body?: unknown
|
||||
params?: Record<string, string>
|
||||
}
|
||||
|
||||
const CALLS: Call[] = []
|
||||
let REPLY: unknown = {}
|
||||
|
||||
class FakeTransport implements GitHubTransport {
|
||||
get(path: string, params?: Record<string, string>): Promise<unknown> {
|
||||
return this.request('GET', path, undefined, params)
|
||||
}
|
||||
|
||||
request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, string>,
|
||||
): Promise<unknown> {
|
||||
const call: Call = { method, path }
|
||||
if (body !== undefined) call.body = body
|
||||
if (params !== undefined) call.params = params
|
||||
CALLS.push(call)
|
||||
return Promise.resolve(REPLY)
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('./accessor.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AccessorModule>()
|
||||
return { ...actual, ghTransport: () => new FakeTransport() }
|
||||
})
|
||||
|
||||
function inv(
|
||||
texts: string[],
|
||||
flags: CLIInvocation['flags'] = {},
|
||||
config: unknown = { token: 't' },
|
||||
): CLIInvocation {
|
||||
return { config, argv: [], paths: [], texts, flags, stdin: null, env: {} }
|
||||
}
|
||||
|
||||
function text(result: CommandFnResult): string {
|
||||
if (result === null) throw new Error('expected a result tuple')
|
||||
return DEC.decode(result[0] as Uint8Array)
|
||||
}
|
||||
|
||||
function reset(reply: unknown = {}): void {
|
||||
CALLS.length = 0
|
||||
REPLY = reply
|
||||
}
|
||||
|
||||
describe('gh tree', () => {
|
||||
it('registers itself under the grammar gh uses', () => {
|
||||
expect(cliSpecFor('gh')).toBe(GH)
|
||||
expect(GH.subcommands.map((c) => c.name)).toEqual(['repo', 'api'])
|
||||
const repo = GH.subcommands.find((c) => c.name === 'repo')
|
||||
expect(repo?.subcommands.map((c) => c.name)).toEqual(['view', 'fork', 'rename'])
|
||||
})
|
||||
|
||||
// A gh write lands on the repository a `github` mount reads, by name
|
||||
// rather than by any vfs path, so the mount cannot invalidate itself.
|
||||
// Without this the executor's post-write cache drop is a no-op and a
|
||||
// committed file still reads back as its pre-write bytes.
|
||||
it('names the mounted resource its writes invalidate', () => {
|
||||
expect(GH.serves).toEqual([ResourceName.GITHUB])
|
||||
})
|
||||
})
|
||||
|
||||
describe('gh repo', () => {
|
||||
it('views the repository the operand names', async () => {
|
||||
reset({ full_name: 'o/r' })
|
||||
await view(inv(['o/r']))
|
||||
expect(CALLS).toEqual([
|
||||
{ method: 'GET', path: '/repos/o/r' },
|
||||
{ method: 'GET', path: '/repos/o/r/readme' },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the install repo when no operand is given', async () => {
|
||||
reset({ full_name: 'cfg/repo' })
|
||||
await view(inv([], {}, { token: 't', repo: 'cfg/repo' }))
|
||||
expect(CALLS[0]?.path).toBe('/repos/cfg/repo')
|
||||
})
|
||||
|
||||
it('refuses a line with no repository anywhere', async () => {
|
||||
reset()
|
||||
await expect(view(inv([]))).rejects.toThrow(/no repository given/)
|
||||
})
|
||||
|
||||
it('refuses a repository that is not OWNER/REPO', async () => {
|
||||
reset()
|
||||
await expect(view(inv(['justaname']))).rejects.toThrow(/OWNER\/REPO/)
|
||||
})
|
||||
|
||||
// gh's format is [HOST/]OWNER/REPO, so the owner and repo are the *last*
|
||||
// two segments. Reading the first two made `github.com/acme/tools` a
|
||||
// request for `github.com/acme` -- a different repository, reported as
|
||||
// success rather than as an error.
|
||||
it('drops the optional host segment rather than shifting the repo', async () => {
|
||||
reset({ full_name: 'acme/tools' })
|
||||
await view(inv(['github.com/acme/tools']))
|
||||
expect(CALLS[0]?.path).toBe('/repos/acme/tools')
|
||||
})
|
||||
|
||||
it('refuses a spec with more segments than a host and a repository', async () => {
|
||||
reset()
|
||||
await expect(view(inv(['a/b/c/d']))).rejects.toThrow(/OWNER\/REPO/)
|
||||
})
|
||||
|
||||
it('names the fork at creation time when --fork-name is given', async () => {
|
||||
reset({ full_name: 'me/renamed' })
|
||||
const out = await fork(inv(['o/r'], { fork_name: 'renamed' }))
|
||||
expect(CALLS).toEqual([{ method: 'POST', path: '/repos/o/r/forks', body: { name: 'renamed' } }])
|
||||
expect(out === null ? '' : text(out)).toContain('me/renamed')
|
||||
})
|
||||
|
||||
it('forks under the source name when it is not', async () => {
|
||||
reset({ full_name: 'me/r' })
|
||||
await fork(inv(['o/r']))
|
||||
expect(CALLS[0]?.body).toEqual({})
|
||||
})
|
||||
|
||||
// gh takes the new name as the operand and the repository to rename as
|
||||
// -R, which is the reverse of what the shape of the line suggests.
|
||||
it('renames the -R repository to the operand', async () => {
|
||||
reset({ full_name: 'me/after' })
|
||||
await rename(inv(['after'], { repo: 'me/before' }))
|
||||
expect(CALLS).toEqual([{ method: 'PATCH', path: '/repos/me/before', body: { name: 'after' } }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('gh api', () => {
|
||||
it('is a GET with no fields, and sends them as query parameters', async () => {
|
||||
reset({ ok: true })
|
||||
await api(inv(['repos/o/r/contents/x'], { raw_field: ['ref=master'], method: 'GET' }))
|
||||
expect(CALLS[0]).toEqual({
|
||||
method: 'GET',
|
||||
path: '/repos/o/r/contents/x',
|
||||
params: { ref: 'master' },
|
||||
})
|
||||
})
|
||||
|
||||
it('is a POST once a field is given', async () => {
|
||||
reset({})
|
||||
await api(inv(['repos/o/r/issues'], { raw_field: ['title=hi'] }))
|
||||
expect(CALLS[0]?.method).toBe('POST')
|
||||
})
|
||||
|
||||
it('sends -f verbatim and reads -F as JSON types', async () => {
|
||||
reset({})
|
||||
await api(
|
||||
inv(['x'], {
|
||||
method: 'PUT',
|
||||
raw_field: ['a=1'],
|
||||
field: ['b=2', 'c=true', 'd=null', 'e=text'],
|
||||
}),
|
||||
)
|
||||
expect(CALLS[0]?.body).toEqual({ a: '1', b: 2, c: true, d: null, e: 'text' })
|
||||
})
|
||||
|
||||
it('keeps everything after the first = in the value', async () => {
|
||||
reset({})
|
||||
await api(inv(['x'], { raw_field: ['content=YQ==\n'] }))
|
||||
expect(CALLS[0]?.body).toEqual({ content: 'YQ==\n' })
|
||||
})
|
||||
|
||||
it('takes an endpoint with or without a leading slash', async () => {
|
||||
reset({})
|
||||
await api(inv(['/user']))
|
||||
expect(CALLS[0]?.path).toBe('/user')
|
||||
})
|
||||
|
||||
it('refuses a field that is not key=value', async () => {
|
||||
reset({})
|
||||
await expect(api(inv(['x'], { raw_field: ['nope'] }))).rejects.toThrow(/key=value/)
|
||||
})
|
||||
|
||||
// Real gh sends no body for a call carrying no fields, so a bare DELETE
|
||||
// is a bare DELETE rather than an empty JSON object with a content type.
|
||||
it('sends no body at all when no field was given', async () => {
|
||||
reset({})
|
||||
await api(inv(['repos/o/r'], { method: 'DELETE' }))
|
||||
expect(CALLS[0]).toEqual({ method: 'DELETE', path: '/repos/o/r' })
|
||||
})
|
||||
|
||||
// -F types a value for a JSON body; on a GET the same value has to reach
|
||||
// the query string, where everything is a string.
|
||||
it('stringifies a typed field when the method puts it in the query', async () => {
|
||||
reset({})
|
||||
await api(inv(['search/code'], { method: 'GET', field: ['per_page=5', 'draft=true'] }))
|
||||
expect(CALLS[0]?.params).toEqual({ per_page: '5', draft: 'true' })
|
||||
expect(CALLS[0]?.body).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// gh prints two tab-separated header lines and then the README verbatim;
|
||||
// with no README there is no `--` separator at all. Probed against 2.85.
|
||||
describe('gh repo view rendering', () => {
|
||||
it('is two headers then the readme', () => {
|
||||
expect(summary({ full_name: 'o/r', description: 'd' }, '# Title\n')).toBe(
|
||||
'name:\to/r\ndescription:\td\n--\n# Title\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the separator without a readme', () => {
|
||||
expect(summary({ full_name: 'o/r', description: null }, null)).toBe(
|
||||
'name:\to/r\ndescription:\t\n',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
// ========= 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 { GhConfigSchema } from '../../../../core/github/config.ts'
|
||||
import { ResourceName } from '../../../../types.ts'
|
||||
import { Operand, Option } from '../../../spec/types.ts'
|
||||
import { registerCliSpec } from '../../specs.ts'
|
||||
import { CLISpec } from '../../types.ts'
|
||||
import { api } from './api.ts'
|
||||
import { fork, rename, view } from './repo.ts'
|
||||
|
||||
// The GitHub CLI, spelled as cli.github.com spells it. The `github` mount
|
||||
// is the read half -- a repository is a tree, so listing and reading it is
|
||||
// `ls` and `cat` -- and this is the write half plus the account-level
|
||||
// operations a filesystem has no shape for: forking, renaming, and `api`
|
||||
// for everything else. Install with a GhConfig; `repo` supplies the
|
||||
// default repository that real gh reads off the current git remote.
|
||||
export const GH = new CLISpec({
|
||||
name: 'gh',
|
||||
description: 'GitHub CLI',
|
||||
configModel: GhConfigSchema,
|
||||
// A write here lands on the same repository a `github` mount reads, and it
|
||||
// lands by name rather than by any vfs path, so the mount cannot invalidate
|
||||
// itself: without this, `gh api -X PUT .../contents/f` followed by
|
||||
// `cat /repo/f` serves the pre-write bytes, and a delete still lists.
|
||||
serves: [ResourceName.GITHUB],
|
||||
subcommands: [
|
||||
new CLISpec({
|
||||
name: 'repo',
|
||||
description: 'Manage repositories',
|
||||
subcommands: [
|
||||
new CLISpec({
|
||||
name: 'view',
|
||||
description: 'View a repository',
|
||||
fn: view,
|
||||
positional: [new Operand({ type: 'str', name: 'REPOSITORY' })],
|
||||
}),
|
||||
new CLISpec({
|
||||
name: 'fork',
|
||||
description: 'Create a fork of a repository',
|
||||
fn: fork,
|
||||
write: true,
|
||||
positional: [new Operand({ type: 'str', name: 'REPOSITORY' })],
|
||||
options: [
|
||||
new Option({
|
||||
long: '--fork-name',
|
||||
type: 'str',
|
||||
description: 'Rename the forked repository',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new CLISpec({
|
||||
name: 'rename',
|
||||
description: 'Rename a repository',
|
||||
fn: rename,
|
||||
write: true,
|
||||
positional: [new Operand({ type: 'str', name: 'NEW-NAME', required: true })],
|
||||
options: [
|
||||
new Option({
|
||||
short: '-R',
|
||||
long: '--repo',
|
||||
type: 'str',
|
||||
description: 'Select another repository, as [HOST/]OWNER/REPO',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new CLISpec({
|
||||
name: 'api',
|
||||
description: 'Make an authenticated GitHub API request',
|
||||
fn: api,
|
||||
write: true,
|
||||
positional: [new Operand({ type: 'str', name: 'ENDPOINT', required: true })],
|
||||
options: [
|
||||
new Option({
|
||||
short: '-X',
|
||||
long: '--method',
|
||||
type: 'str',
|
||||
description: 'The HTTP method for the request',
|
||||
}),
|
||||
new Option({
|
||||
short: '-f',
|
||||
long: '--raw-field',
|
||||
type: 'str',
|
||||
multiple: true,
|
||||
description: 'Add a string parameter in key=value format',
|
||||
}),
|
||||
new Option({
|
||||
short: '-F',
|
||||
long: '--field',
|
||||
type: 'str',
|
||||
multiple: true,
|
||||
description: 'Add a typed parameter in key=value format',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
registerCliSpec(GH)
|
||||
@@ -0,0 +1,62 @@
|
||||
// ========= 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 { FlagView } from '../../../spec/types.ts'
|
||||
import { forkRepo, login, readReadme, renameRepo, viewRepo } from '../../../../core/github/repo.ts'
|
||||
import type { CommandFnResult } from '../../../config.ts'
|
||||
import type { CLIInvocation } from '../../types.ts'
|
||||
import { ghRepo, ghTransport, textOut } from './accessor.ts'
|
||||
|
||||
/**
|
||||
* gh's own text view of a repository: two tab-separated header lines and
|
||||
* then the README verbatim, with the `--` separator omitted entirely when
|
||||
* there is no README. Probed against gh 2.85, whose description line is
|
||||
* present and empty for a repository that has none.
|
||||
*/
|
||||
export function summary(repo: unknown, readme: string | null): string {
|
||||
const fields = (repo ?? {}) as { full_name?: unknown; description?: unknown }
|
||||
const name = typeof fields.full_name === 'string' ? fields.full_name : ''
|
||||
const description = typeof fields.description === 'string' ? fields.description : ''
|
||||
const head = `name:\t${name}\ndescription:\t${description}\n`
|
||||
return readme === null ? head : `${head}--\n${readme}`
|
||||
}
|
||||
|
||||
export async function view(inv: CLIInvocation): Promise<CommandFnResult> {
|
||||
const transport = ghTransport(inv.config)
|
||||
const ref = ghRepo(inv.config, inv.texts[0])
|
||||
const repo = await viewRepo(transport, ref)
|
||||
return textOut(summary(repo, await readReadme(transport, ref)))
|
||||
}
|
||||
|
||||
export async function fork(inv: CLIInvocation): Promise<CommandFnResult> {
|
||||
const fl = new FlagView(inv.flags)
|
||||
const transport = ghTransport(inv.config)
|
||||
const source = ghRepo(inv.config, inv.texts[0])
|
||||
const name = fl.asStr('fork_name') ?? undefined
|
||||
const forked = (await forkRepo(transport, source, name)) as { full_name?: string }
|
||||
const full = forked.full_name ?? `${await login(transport)}/${name ?? source.repo}`
|
||||
return textOut(`✓ Created fork ${full}\n`)
|
||||
}
|
||||
|
||||
export async function rename(inv: CLIInvocation): Promise<CommandFnResult> {
|
||||
const fl = new FlagView(inv.flags)
|
||||
const transport = ghTransport(inv.config)
|
||||
// gh takes the *new name* as the operand and the repository to rename as
|
||||
// -R, which is the reverse of what the shape of the line suggests.
|
||||
const target = ghRepo(inv.config, fl.asStr('repo') ?? undefined)
|
||||
const name = inv.texts[0] ?? ''
|
||||
if (name === '') throw new Error('a new repository name is required')
|
||||
const renamed = (await renameRepo(transport, target, name)) as { full_name?: string }
|
||||
return textOut(`✓ Renamed repository ${renamed.full_name ?? name}\n`)
|
||||
}
|
||||
@@ -67,6 +67,20 @@ function flagRows(spec: CommandSpec): [string, string][] {
|
||||
return spec.options.map((o) => [flagDisplay(o), o.description ?? ''])
|
||||
}
|
||||
|
||||
/**
|
||||
* One operand's placeholder outside the clap dialect.
|
||||
*
|
||||
* A spec that named the slot gets that name, which is what argparse prints
|
||||
* too (it renders the dest, not a generic word); a spec that did not falls
|
||||
* back to the type. Only `gh` names one outside clap today, so this is the
|
||||
* difference between `gh api [flags] <text>` and upstream's
|
||||
* `gh api <endpoint>`.
|
||||
*/
|
||||
function slot(operand: Operand): string {
|
||||
if (operand.name !== '') return `<${operand.name}>`
|
||||
return operand.type === 'path' ? '<path>' : '<text>'
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one command's help; a CLI group is the same shape plus a Commands
|
||||
* section. `subcommands` carries (name, one-line help) rows for a CLI
|
||||
@@ -84,11 +98,11 @@ function usageLine(
|
||||
if (spec.options.length > 0) bits.push(clap ? '[OPTIONS]' : '[flags]')
|
||||
if (subcommands.length > 0) bits.push(clap ? '<COMMAND>' : '<command> [<args>]')
|
||||
for (const op of spec.positional) {
|
||||
bits.push(clap ? operandSlot(op) : op.type === 'path' ? '<path>' : '<text>')
|
||||
bits.push(clap ? operandSlot(op) : slot(op))
|
||||
}
|
||||
if (spec.rest !== null) {
|
||||
if (clap) bits.push(operandSlot(spec.rest, !spec.rest.required))
|
||||
else bits.push(spec.rest.type === 'path' ? '[<path>...]' : '[<text>...]')
|
||||
else bits.push(`[${slot(spec.rest)}...]`)
|
||||
}
|
||||
return `Usage: ${bits.join(' ')}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// ========= 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { GitHubApiError, HttpGitHubTransport } from './_client.ts'
|
||||
|
||||
interface Seen {
|
||||
url: string
|
||||
method: string
|
||||
body: string | null
|
||||
contentType: string | null
|
||||
}
|
||||
|
||||
const SEEN: Seen[] = []
|
||||
let REPLY: { status: number; body: string } = { status: 200, body: '{"ok":true}' }
|
||||
const REAL_FETCH = globalThis.fetch
|
||||
|
||||
function transport(): HttpGitHubTransport {
|
||||
return new HttpGitHubTransport({ token: 't', baseUrl: 'https://api.example.test' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
SEEN.length = 0
|
||||
REPLY = { status: 200, body: '{"ok":true}' }
|
||||
globalThis.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const req = new Request(input, init)
|
||||
SEEN.push({
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
body: typeof init?.body === 'string' ? init.body : null,
|
||||
contentType: req.headers.get('content-type'),
|
||||
})
|
||||
return Promise.resolve(
|
||||
new Response(REPLY.body === '' ? null : REPLY.body, {
|
||||
status: REPLY.status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
}) as typeof globalThis.fetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = REAL_FETCH
|
||||
})
|
||||
|
||||
describe('HttpGitHubTransport', () => {
|
||||
it('puts params on the query string and leaves the body off a GET', async () => {
|
||||
await transport().get('/repos/o/r/git/trees/main', { recursive: '1' })
|
||||
expect(SEEN[0]?.url).toBe('https://api.example.test/repos/o/r/git/trees/main?recursive=1')
|
||||
expect(SEEN[0]?.body).toBeNull()
|
||||
})
|
||||
|
||||
it('sends a body as JSON on a method that carries one', async () => {
|
||||
await transport().request('PATCH', '/repos/o/r', { name: 'after' })
|
||||
expect(SEEN[0]?.method).toBe('PATCH')
|
||||
expect(SEEN[0]?.body).toBe('{"name":"after"}')
|
||||
expect(SEEN[0]?.contentType).toContain('application/json')
|
||||
})
|
||||
|
||||
// Real gh sends nothing for a fieldless call; an empty JSON object plus a
|
||||
// content type is a different request, and some endpoints read it as one.
|
||||
it('sends no body and no content type when there is nothing to send', async () => {
|
||||
await transport().request('DELETE', '/repos/o/r')
|
||||
expect(SEEN[0]?.method).toBe('DELETE')
|
||||
expect(SEEN[0]?.body).toBeNull()
|
||||
expect(SEEN[0]?.contentType).toBeNull()
|
||||
})
|
||||
|
||||
// Octokit reads `{...}` in a url as a route-template placeholder and drops
|
||||
// the segment when nothing fills it -- silently, with no error. `gh api`
|
||||
// takes its endpoint straight from the agent, so a brace must survive as
|
||||
// one rather than deleting the path segment it sits in.
|
||||
it('keeps a braced path segment instead of letting it vanish', async () => {
|
||||
await transport().get('/repos/o/r/contents/{tmpl}')
|
||||
expect(SEEN[0]?.url).toBe('https://api.example.test/repos/o/r/contents/%7Btmpl%7D')
|
||||
})
|
||||
|
||||
it('percent-encodes a space in a path', async () => {
|
||||
await transport().get('/repos/o/r/contents/my file.txt')
|
||||
expect(SEEN[0]?.url).toBe('https://api.example.test/repos/o/r/contents/my%20file.txt')
|
||||
})
|
||||
|
||||
// 204 and an empty 202 have no body; the caller gets null on a call that
|
||||
// worked, not the empty string octokit reports.
|
||||
it('decodes an empty response to null', async () => {
|
||||
REPLY = { status: 204, body: '' }
|
||||
expect(await transport().request('DELETE', '/repos/o/r')).toBeNull()
|
||||
})
|
||||
|
||||
// github.com asks for a second between writes and enforces it as a
|
||||
// secondary rate limit; a self-hosted host or a fake imposes no such thing,
|
||||
// and paying it there costs a second per written file for nothing.
|
||||
it('does not hold writes a second apart against a non-github.com host', async () => {
|
||||
const started = Date.now()
|
||||
const t = transport()
|
||||
await t.request('PUT', '/repos/o/r/contents/a')
|
||||
await t.request('PUT', '/repos/o/r/contents/b')
|
||||
await t.request('PUT', '/repos/o/r/contents/c')
|
||||
expect(Date.now() - started).toBeLessThan(500)
|
||||
expect(SEEN).toHaveLength(3)
|
||||
})
|
||||
|
||||
// Octokit merges loose parameters into the same object that carries
|
||||
// `url`, `method` and `headers`, so a field the agent typed could steer
|
||||
// the request instead of riding in it: `gh api X -f url=...` retargeted
|
||||
// the call. Body and query travel in their own containers.
|
||||
it('does not let a field named url steer the request', async () => {
|
||||
await transport().request('POST', '/repos/o/r/issues', {
|
||||
url: 'https://elsewhere.test/x',
|
||||
method: 'DELETE',
|
||||
title: 'hi',
|
||||
})
|
||||
expect(SEEN[0]?.url).toBe('https://api.example.test/repos/o/r/issues')
|
||||
expect(SEEN[0]?.method).toBe('POST')
|
||||
expect(JSON.parse(SEEN[0]?.body ?? '{}')).toEqual({
|
||||
url: 'https://elsewhere.test/x',
|
||||
method: 'DELETE',
|
||||
title: 'hi',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let a query field named url steer the request', async () => {
|
||||
await transport().get('/search/code', { url: 'https://elsewhere.test/x', q: 'a' })
|
||||
expect(SEEN[0]?.url).toBe(
|
||||
'https://api.example.test/search/code?url=https%3A%2F%2Felsewhere.test%2Fx&q=a',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a failure as a GitHubApiError carrying the status', async () => {
|
||||
REPLY = { status: 404, body: '{"message":"Not Found"}' }
|
||||
await expect(transport().get('/repos/o/r')).rejects.toMatchObject({
|
||||
constructor: GitHubApiError,
|
||||
status: 404,
|
||||
message: 'Not Found',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,39 +12,112 @@
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { Octokit } from '@octokit/core'
|
||||
import { RequestError } from '@octokit/request-error'
|
||||
import { retry } from '@octokit/plugin-retry'
|
||||
import { throttling } from '@octokit/plugin-throttling'
|
||||
|
||||
export const GITHUB_API_BASE = 'https://api.github.com'
|
||||
export const GITHUB_API_VERSION = '2022-11-28'
|
||||
// A rate limit is a wait, not a failure, but an unbounded wait is a hang;
|
||||
// three attempts is what octokit's own docs use for an unattended client.
|
||||
const GITHUB_RETRIES = 3
|
||||
|
||||
export interface GitHubTransport {
|
||||
get(path: string, params?: Record<string, string>): Promise<unknown>
|
||||
request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, string>,
|
||||
): Promise<unknown>
|
||||
}
|
||||
|
||||
const Kit = Octokit.plugin(retry, throttling)
|
||||
|
||||
/**
|
||||
* Octokit reads `{name}` in a url as a route-template placeholder and drops
|
||||
* the segment when nothing fills it, silently and without an error. Every
|
||||
* caller here passes a path that is already final -- `gh api` takes one
|
||||
* straight from the agent's command line -- so the braces are escaped to
|
||||
* the percent forms a server sees them as.
|
||||
*
|
||||
* Args:
|
||||
* path (string): the request path as the caller spelled it.
|
||||
*
|
||||
* Returns:
|
||||
* string: the path with `{` and `}` percent-encoded.
|
||||
*/
|
||||
function escapeBraces(path: string): string {
|
||||
return path.replace(/\{/g, '%7B').replace(/\}/g, '%7D')
|
||||
}
|
||||
|
||||
export class HttpGitHubTransport implements GitHubTransport {
|
||||
readonly token: string
|
||||
readonly baseUrl: string
|
||||
private readonly kit: InstanceType<typeof Kit>
|
||||
|
||||
constructor(opts: { token: string; baseUrl?: string }) {
|
||||
this.token = opts.token
|
||||
this.baseUrl = opts.baseUrl ?? GITHUB_API_BASE
|
||||
}
|
||||
|
||||
async get(path: string, params?: Record<string, string>): Promise<unknown> {
|
||||
const url = new URL(this.baseUrl + path)
|
||||
if (params !== undefined) {
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
||||
}
|
||||
const r = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': GITHUB_API_VERSION,
|
||||
this.kit = new Kit({
|
||||
auth: opts.token,
|
||||
baseUrl: this.baseUrl,
|
||||
request: { retries: GITHUB_RETRIES },
|
||||
throttle: {
|
||||
// The write limiter holds every non-GET a second apart, which is
|
||||
// github.com's own guidance and its secondary rate limit. That limit
|
||||
// is github.com's, not the API's: a GHES install does not impose it
|
||||
// and a fake certainly does not, so paying it there would add a
|
||||
// second per write for nothing.
|
||||
enabled: this.baseUrl === GITHUB_API_BASE,
|
||||
onRateLimit: (_after: number, _options: unknown, _kit: unknown, count: number) =>
|
||||
count < GITHUB_RETRIES,
|
||||
onSecondaryRateLimit: (_after: number, _options: unknown, _kit: unknown, count: number) =>
|
||||
count < GITHUB_RETRIES,
|
||||
},
|
||||
})
|
||||
if (!r.ok) {
|
||||
const body = await r.text().catch(() => '')
|
||||
throw new GitHubApiError(`GitHub ${path} → ${String(r.status)} ${body}`, r.status)
|
||||
}
|
||||
|
||||
get(path: string, params?: Record<string, string>): Promise<unknown> {
|
||||
return this.request('GET', path, undefined, params)
|
||||
}
|
||||
|
||||
async request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, string>,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
// Octokit reads loose parameters off the same object that carries
|
||||
// `url`, `method` and `headers`, so a field the agent typed would
|
||||
// steer the request rather than ride in it: `gh api X -f url=...`
|
||||
// retargeted the call. The query is spelled into the url and the body
|
||||
// travels as `data`, which octokit sends verbatim, so neither can
|
||||
// collide with a transport option. A call with neither sends no body
|
||||
// at all, which is what a bare DELETE has to look like on the wire.
|
||||
const query = new URLSearchParams(params ?? {}).toString()
|
||||
const r = await this.kit.request({
|
||||
method: method.toUpperCase(),
|
||||
url: escapeBraces(path) + (query === '' ? '' : `?${query}`),
|
||||
headers: { 'X-GitHub-Api-Version': GITHUB_API_VERSION },
|
||||
...(body === undefined ? {} : { data: body }),
|
||||
})
|
||||
// 204 and an empty 202 decode to '' rather than a body; the caller gets
|
||||
// null on a call that worked.
|
||||
return r.data === '' ? null : r.data
|
||||
} catch (err) {
|
||||
if (err instanceof RequestError) {
|
||||
// Octokit composes its message as `<message> - <documentation_url>`.
|
||||
// The suffix is octokit's, not GitHub's: the service says only the
|
||||
// message, real gh prints only the message, and the python client
|
||||
// reports only the message. Read it off the body rather than
|
||||
// trimming the composed string.
|
||||
const data = err.response?.data as { message?: string } | undefined
|
||||
const message = typeof data?.message === 'string' ? data.message : err.message
|
||||
throw new GitHubApiError(message, err.status)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
return r.json()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// ========= 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 { z } from 'zod'
|
||||
import type { REDACTED_SECRET } from '../../resource/secrets.ts'
|
||||
import { redactConfigWithSchema, secretStr } from '../../resource/secrets.ts'
|
||||
import { normalizeFields } from '../../utils/normalize.ts'
|
||||
|
||||
export const GhConfigSchema = z.object({
|
||||
token: secretStr(),
|
||||
baseUrl: z.string().optional(),
|
||||
repo: z.string().optional(),
|
||||
branch: z.string().optional(),
|
||||
})
|
||||
|
||||
// Derived, not declared twice. The schema is the one doing real work --
|
||||
// it validates an install's config and carries the `secretStr` marker
|
||||
// redaction reads -- so a hand-written twin only adds a shape that can
|
||||
// drift from it, which is how `branch` reached the schema and not the type.
|
||||
export type GhConfig = z.infer<typeof GhConfigSchema>
|
||||
|
||||
export type GhConfigRedacted = Omit<GhConfig, 'token'> & {
|
||||
token: typeof REDACTED_SECRET
|
||||
}
|
||||
|
||||
export function redactGhConfig(config: GhConfig): GhConfigRedacted {
|
||||
return redactConfigWithSchema(GhConfigSchema, config) as unknown as GhConfigRedacted
|
||||
}
|
||||
|
||||
export function normalizeGhConfig(input: Record<string, unknown>): GhConfig {
|
||||
return normalizeFields(input, {}) as unknown as GhConfig
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// ========= 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 { describe, expect, it } from 'vitest'
|
||||
import { expand } from './placeholder.ts'
|
||||
import type { GhConfig } from './config.ts'
|
||||
|
||||
const CONFIG = { token: 't', repo: 'acme/tools', branch: 'main' } as unknown as GhConfig
|
||||
|
||||
describe('gh placeholders', () => {
|
||||
it('expands the three gh documents', () => {
|
||||
expect(expand('repos/{owner}/{repo}/branches/{branch}', CONFIG)).toBe(
|
||||
'repos/acme/tools/branches/main',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a path with no braces untouched', () => {
|
||||
expect(expand('/user', CONFIG)).toBe('/user')
|
||||
})
|
||||
|
||||
// gh leaves an unknown placeholder alone and lets the request go out; the
|
||||
// API answers 404 for the literal segment. Probed against gh 2.85, which
|
||||
// returns GitHub's own Not Found rather than a client-side error.
|
||||
it('leaves an unknown placeholder on the wire', () => {
|
||||
expect(expand('repos/{owner}/{nope}', CONFIG)).toBe('repos/acme/{nope}')
|
||||
})
|
||||
|
||||
it('reads the owner off a host-qualified repo', () => {
|
||||
const config = { token: 't', repo: 'github.com/acme/tools' } as unknown as GhConfig
|
||||
expect(expand('repos/{owner}/{repo}', config)).toBe('repos/acme/tools')
|
||||
})
|
||||
|
||||
it('refuses owner when the install names no repository', () => {
|
||||
const config = { token: 't' } as unknown as GhConfig
|
||||
expect(() => expand('repos/{owner}/x', config)).toThrow(/unable to expand placeholder/)
|
||||
})
|
||||
|
||||
// `branch` is its own field: an install may name a repository without
|
||||
// pinning a branch, and gh reports the failure rather than guessing one.
|
||||
it('refuses branch when the install pins none', () => {
|
||||
const config = { token: 't', repo: 'acme/tools' } as unknown as GhConfig
|
||||
expect(() => expand('repos/{owner}/{repo}/branches/{branch}', config)).toThrow(
|
||||
/no `branch` on the install/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
// ========= 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 type { GhConfig } from './config.ts'
|
||||
import { parseRepo } from './repo.ts'
|
||||
|
||||
const PLACEHOLDER_RE = /\{(owner|repo|branch)\}/g
|
||||
const EXPAND_ERROR = 'unable to expand placeholder in path'
|
||||
|
||||
function value(name: string, config: GhConfig): string {
|
||||
if (name === 'branch') {
|
||||
if (config.branch === undefined || config.branch === '') {
|
||||
throw new Error(`${EXPAND_ERROR}: no \`branch\` on the install`)
|
||||
}
|
||||
return config.branch
|
||||
}
|
||||
if (config.repo === undefined || config.repo === '') {
|
||||
throw new Error(`${EXPAND_ERROR}: no \`repo\` on the install`)
|
||||
}
|
||||
const ref = parseRepo(config.repo)
|
||||
return name === 'owner' ? ref.owner : ref.repo
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand gh's repository placeholders in an endpoint or field value.
|
||||
*
|
||||
* Real gh fills `{owner}`, `{repo}` and `{branch}` from the repository of
|
||||
* the current directory, which is what most of its own documented examples
|
||||
* are written with (`gh api repos/{owner}/{repo}/releases`); an install's
|
||||
* `repo`/`branch` are the workspace's stand-in for that. Any other brace
|
||||
* pair is left exactly as typed and reaches the wire, which is gh's
|
||||
* behavior too: it is a path segment, not a template error.
|
||||
*/
|
||||
export function expand(text: string, config: GhConfig): string {
|
||||
if (!text.includes('{')) return text
|
||||
return text.replace(PLACEHOLDER_RE, (_m, name: string) => value(name, config))
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// ========= 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 { describe, expect, it } from 'vitest'
|
||||
import { GitHubAccessor } from '../../accessor/github.ts'
|
||||
import { RAMIndexCacheStore } from '../../cache/index/ram.ts'
|
||||
import { PathSpec } from '../../types.ts'
|
||||
import { populateIndex } from './tree.ts'
|
||||
import { read } from './read.ts'
|
||||
import type { GitHubTransport } from './_client.ts'
|
||||
|
||||
const ENC = new TextEncoder()
|
||||
|
||||
interface Probe {
|
||||
trees: number
|
||||
blobs: string[]
|
||||
}
|
||||
|
||||
// The blob a given sha holds, so a read that used a stale row is visible as
|
||||
// the wrong bytes rather than as an error.
|
||||
const BLOBS: Record<string, string> = { bbb: 'one\n', ccc: 'two\n' }
|
||||
|
||||
function accessorFor(sha: string, probe: Probe): GitHubAccessor {
|
||||
const transport = {
|
||||
get: (path: string) => {
|
||||
if (path.includes('/git/trees/')) {
|
||||
probe.trees += 1
|
||||
return Promise.resolve({
|
||||
tree: [
|
||||
{ path: 'src', type: 'tree' as const, sha: 'aaa' },
|
||||
{ path: 'src/main.py', type: 'blob' as const, sha, size: 4 },
|
||||
],
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
const blobSha = path.slice(path.lastIndexOf('/') + 1)
|
||||
probe.blobs.push(blobSha)
|
||||
return Promise.resolve({
|
||||
content: Buffer.from(ENC.encode(BLOBS[blobSha] ?? '')).toString('base64'),
|
||||
encoding: 'base64',
|
||||
})
|
||||
},
|
||||
} as unknown as GitHubTransport
|
||||
return new GitHubAccessor({
|
||||
transport,
|
||||
owner: 'acme',
|
||||
repo: 'proj',
|
||||
ref: 'main',
|
||||
defaultBranch: 'main',
|
||||
})
|
||||
}
|
||||
|
||||
async function seeded(sha: string): Promise<RAMIndexCacheStore> {
|
||||
const index = new RAMIndexCacheStore()
|
||||
await populateIndex(index, [
|
||||
{ path: 'src', type: 'tree' as const, sha: 'aaa' },
|
||||
{ path: 'src/main.py', type: 'blob' as const, sha, size: 4 },
|
||||
])
|
||||
return index
|
||||
}
|
||||
|
||||
function spec(p: string): PathSpec {
|
||||
return new PathSpec({ resourcePath: p.slice(1), virtual: p, directory: '/src' })
|
||||
}
|
||||
|
||||
describe('github read freshness', () => {
|
||||
// The row survives an invalidation carrying the *pre-write* blob sha, so
|
||||
// trusting it served the old bytes. Freshness is tracked per directory,
|
||||
// so the parent listing is what says the row aged out.
|
||||
it('refetches the tree when the parent listing expired', async () => {
|
||||
const index = await seeded('bbb')
|
||||
await index.invalidate()
|
||||
const probe: Probe = { trees: 0, blobs: [] }
|
||||
const out = await read(accessorFor('ccc', probe), spec('/src/main.py'), index)
|
||||
expect(new TextDecoder().decode(out)).toBe('two\n')
|
||||
expect(probe.trees).toBe(1)
|
||||
expect(probe.blobs).toEqual(['ccc'])
|
||||
})
|
||||
|
||||
it('trusts a live listing without refetching', async () => {
|
||||
const index = await seeded('bbb')
|
||||
const probe: Probe = { trees: 0, blobs: [] }
|
||||
const out = await read(accessorFor('ccc', probe), spec('/src/main.py'), index)
|
||||
expect(new TextDecoder().decode(out)).toBe('one\n')
|
||||
expect(probe.trees).toBe(0)
|
||||
})
|
||||
|
||||
// A miss against a live index is a real absence. Refilling here would
|
||||
// spend a full recursive-tree fetch on every ENOENT.
|
||||
it('does not refetch on a real miss', async () => {
|
||||
const index = await seeded('bbb')
|
||||
const probe: Probe = { trees: 0, blobs: [] }
|
||||
await expect(read(accessorFor('ccc', probe), spec('/src/gone.py'), index)).rejects.toThrow()
|
||||
expect(probe.trees).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -12,10 +12,12 @@
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { LookupStatus } from '../../cache/index/config.ts'
|
||||
import { mountPrefixOf } from '../../utils/key_prefix.ts'
|
||||
import type { GitHubAccessor } from '../../accessor/github.ts'
|
||||
import type { IndexCacheStore } from '../../cache/index/store.ts'
|
||||
import type { PathSpec } from '../../types.ts'
|
||||
import { refillIndex } from './tree.ts'
|
||||
import { fetchBlob } from './_client.ts'
|
||||
import { stripSlash } from '../../utils/slash.ts'
|
||||
import { enoent } from '../../utils/errors.ts'
|
||||
@@ -40,6 +42,11 @@ function indexKey(p: string): string {
|
||||
return trimmed === '' ? '/' : `/${trimmed}`
|
||||
}
|
||||
|
||||
function parentKey(key: string): string {
|
||||
const cut = key.lastIndexOf('/')
|
||||
return cut <= 0 ? '/' : key.slice(0, cut)
|
||||
}
|
||||
|
||||
export async function read(
|
||||
accessor: GitHubAccessor,
|
||||
path: PathSpec,
|
||||
@@ -47,7 +54,19 @@ export async function read(
|
||||
): Promise<Uint8Array> {
|
||||
const p = stripPrefix(path)
|
||||
if (index === undefined) throw enoent(path)
|
||||
const result = await index.get(indexKey(p))
|
||||
const key = indexKey(p)
|
||||
// Freshness is tracked per directory, never per entry, so a blob's row
|
||||
// is exactly as fresh as its parent's listing and `get` can never report
|
||||
// staleness of its own. The parent is therefore the probe: after a write
|
||||
// invalidated the index the row survives carrying the *pre-write* blob
|
||||
// sha, and reading it back served the old bytes. A miss is not a probe
|
||||
// either -- against a live index it is a real absence, and refetching the
|
||||
// whole tree on every ENOENT costs a recursive-tree call per miss.
|
||||
if (!accessor.truncated) {
|
||||
const parent = await index.listDir(parentKey(key))
|
||||
if (parent.status === LookupStatus.EXPIRED) await refillIndex(accessor, index)
|
||||
}
|
||||
const result = await index.get(key)
|
||||
if (result.entry === undefined || result.entry === null) throw enoent(path)
|
||||
if (result.entry.resourceType === 'folder') throw eisdir(p)
|
||||
return fetchBlob(accessor.transport, accessor.owner, accessor.repo, result.entry.id)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// ========= 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 { describe, expect, it } from 'vitest'
|
||||
import { GitHubAccessor } from '../../accessor/github.ts'
|
||||
import { RAMIndexCacheStore } from '../../cache/index/ram.ts'
|
||||
import { PathSpec } from '../../types.ts'
|
||||
import { populateIndex } from './tree.ts'
|
||||
import { readdir } from './readdir.ts'
|
||||
import type { GitHubTransport } from './_client.ts'
|
||||
|
||||
const TREE = [
|
||||
{ path: 'README.md', type: 'blob' as const, sha: 'eee', size: 50 },
|
||||
{ path: 'src', type: 'tree' as const, sha: 'aaa' },
|
||||
{ path: 'src/main.py', type: 'blob' as const, sha: 'bbb', size: 120 },
|
||||
]
|
||||
|
||||
function accessorFor(probe: { trees: number }): GitHubAccessor {
|
||||
const transport = {
|
||||
get: () => {
|
||||
probe.trees += 1
|
||||
return Promise.resolve({ tree: TREE, truncated: false })
|
||||
},
|
||||
} as unknown as GitHubTransport
|
||||
return new GitHubAccessor({
|
||||
transport,
|
||||
owner: 'acme',
|
||||
repo: 'proj',
|
||||
ref: 'main',
|
||||
defaultBranch: 'main',
|
||||
})
|
||||
}
|
||||
|
||||
async function seeded(): Promise<RAMIndexCacheStore> {
|
||||
const index = new RAMIndexCacheStore()
|
||||
await populateIndex(index, TREE)
|
||||
return index
|
||||
}
|
||||
|
||||
function spec(p: string): PathSpec {
|
||||
return new PathSpec({ resourcePath: p.slice(1), virtual: p, directory: p })
|
||||
}
|
||||
|
||||
describe('github readdir freshness', () => {
|
||||
// The index *is* the listing here, seeded once from the recursive tree,
|
||||
// so an expired one is a tree that aged out rather than a repository that
|
||||
// emptied: before the refill `ls` exited 0 with no output once the
|
||||
// day-long TTL lapsed, and reported the mount root missing after a write
|
||||
// invalidated it.
|
||||
it('refetches the tree when the listing expired', async () => {
|
||||
const index = await seeded()
|
||||
await index.invalidate()
|
||||
const probe = { trees: 0 }
|
||||
expect(await readdir(accessorFor(probe), spec('/'), index)).toEqual(['/README.md', '/src'])
|
||||
expect(probe.trees).toBe(1)
|
||||
})
|
||||
|
||||
it('does not refetch on a real miss', async () => {
|
||||
const index = await seeded()
|
||||
const probe = { trees: 0 }
|
||||
await expect(readdir(accessorFor(probe), spec('/nope'), index)).rejects.toThrow()
|
||||
expect(probe.trees).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import { LookupStatus } from '../../cache/index/config.ts'
|
||||
import type { IndexCacheStore } from '../../cache/index/store.ts'
|
||||
import type { PathSpec } from '../../types.ts'
|
||||
import { fetchDirTree } from './_client.ts'
|
||||
import { refillIndex } from './tree.ts'
|
||||
import { IndexEntry } from '../../cache/index/config.ts'
|
||||
import { stripSlash } from '../../utils/slash.ts'
|
||||
import { enoent } from '../../utils/errors.ts'
|
||||
@@ -49,7 +50,15 @@ export async function readdir(
|
||||
const stripped = stripPrefix(path)
|
||||
const key = normalizeKey(stripped)
|
||||
|
||||
const listing = await index.listDir(key)
|
||||
let listing = await index.listDir(key)
|
||||
// The index is the whole listing here, not a cache in front of one, so an
|
||||
// *expired* answer means the tree aged out, not that the path is gone.
|
||||
// Refetch once and ask again. A NOT_FOUND against a live index is a real
|
||||
// absence and must not cost a tree fetch: refilling on any miss spends a
|
||||
// recursive-tree call on every ENOENT.
|
||||
if (listing.status === LookupStatus.EXPIRED && !accessor.truncated) {
|
||||
if (await refillIndex(accessor, index)) listing = await index.listDir(key)
|
||||
}
|
||||
if (listing.entries !== undefined && listing.entries !== null) {
|
||||
return prefix !== '' && listing.entries.length > 0 && !listing.entries[0]?.startsWith(prefix)
|
||||
? listing.entries.map((e) => prefix + e)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// ========= 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 { GitHubApiError, type GitHubTransport } from './_client.ts'
|
||||
import { decodeBase64 } from '../../utils/base64.ts'
|
||||
|
||||
export interface RepoRef {
|
||||
owner: string
|
||||
repo: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 read `github.com/acme/tools` as owner `github.com`, repo `acme` --
|
||||
* a different repository, reported as success.
|
||||
*
|
||||
* Args:
|
||||
* spec (string): the repository as the line spelled it.
|
||||
*
|
||||
* Returns:
|
||||
* RepoRef: the owner and repository names.
|
||||
*/
|
||||
export function parseRepo(spec: string): RepoRef {
|
||||
const parts = spec.split('/')
|
||||
const repo = parts.pop()
|
||||
const owner = parts.pop()
|
||||
if (owner === undefined || repo === undefined || owner === '' || repo === '') {
|
||||
throw new Error(`expected the "[HOST/]OWNER/REPO" format, got "${spec}"`)
|
||||
}
|
||||
// One more segment is a host; two is not a repository any spelling reaches.
|
||||
if (parts.length > 1) {
|
||||
throw new Error(`expected the "[HOST/]OWNER/REPO" format, got "${spec}"`)
|
||||
}
|
||||
return { owner, repo }
|
||||
}
|
||||
|
||||
export async function login(transport: GitHubTransport): Promise<string> {
|
||||
const me = (await transport.get('/user')) as { login?: string }
|
||||
return me.login ?? ''
|
||||
}
|
||||
|
||||
export function viewRepo(transport: GitHubTransport, ref: RepoRef): Promise<unknown> {
|
||||
return transport.get(`/repos/${ref.owner}/${ref.repo}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository's README as text, or null when it has none.
|
||||
*/
|
||||
export async function readReadme(transport: GitHubTransport, ref: RepoRef): Promise<string | null> {
|
||||
let data: unknown
|
||||
try {
|
||||
data = await transport.get(`/repos/${ref.owner}/${ref.repo}/readme`)
|
||||
} catch (err) {
|
||||
if (err instanceof GitHubApiError && err.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
const content = (data as { content?: unknown } | null)?.content
|
||||
if (typeof content !== 'string') return null
|
||||
return new TextDecoder().decode(decodeBase64(content))
|
||||
}
|
||||
|
||||
export function forkRepo(
|
||||
transport: GitHubTransport,
|
||||
ref: RepoRef,
|
||||
name?: string,
|
||||
): Promise<unknown> {
|
||||
const body = name === undefined ? {} : { name }
|
||||
return transport.request('POST', `/repos/${ref.owner}/${ref.repo}/forks`, body)
|
||||
}
|
||||
|
||||
export function renameRepo(
|
||||
transport: GitHubTransport,
|
||||
ref: RepoRef,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
return transport.request('PATCH', `/repos/${ref.owner}/${ref.repo}`, { name })
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import type { GitHubAccessor } from '../../accessor/github.ts'
|
||||
import { fetchTree } from './_client.ts'
|
||||
import type { IndexCacheStore } from '../../cache/index/store.ts'
|
||||
import type { IndexEntry } from '../../cache/index/config.ts'
|
||||
import type { GitHubTreeItem } from './_client.ts'
|
||||
@@ -35,3 +37,40 @@ export async function populateIndex(index: IndexCacheStore, tree: GitHubTreeItem
|
||||
}
|
||||
await Promise.all([...dirs].map(([parent, entries]) => index.setDir(parent, entries)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetch the recursive tree and re-seed the index from it.
|
||||
*
|
||||
* The mount fetches the whole tree once and seeds the index with it, so
|
||||
* the index is the listing rather than a cache in front of one. That makes
|
||||
* a cleared or expired index indistinguishable from an empty repository --
|
||||
* `ls` reported the mount root missing after an invalidation, and reported
|
||||
* nothing at all once the day-long TTL lapsed. This is the refill that
|
||||
* makes dropping the index mean "refetch", which is what invalidating it
|
||||
* was always supposed to mean.
|
||||
*
|
||||
* Args:
|
||||
* accessor (GitHubAccessor): the mount's accessor, holding the transport
|
||||
* and the ref to refetch.
|
||||
* index (IndexCacheStore | undefined): the index to re-seed.
|
||||
*
|
||||
* Returns:
|
||||
* boolean: whether a refill happened; false when there is no index to
|
||||
* seed, so a caller does not retry a lookup that cannot change.
|
||||
*/
|
||||
export async function refillIndex(
|
||||
accessor: GitHubAccessor,
|
||||
index: IndexCacheStore | undefined,
|
||||
): Promise<boolean> {
|
||||
if (index === undefined) return false
|
||||
const { tree, truncated } = await fetchTree(
|
||||
accessor.transport,
|
||||
accessor.owner,
|
||||
accessor.repo,
|
||||
accessor.ref,
|
||||
)
|
||||
accessor.truncated = truncated
|
||||
accessor.tree = buildTreeMap(tree)
|
||||
await populateIndex(index, tree)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -718,6 +718,7 @@ export { DISCORD } from './commands/cli/builtin/discord/index.ts'
|
||||
export { NTN } from './commands/cli/builtin/ntn/index.ts'
|
||||
export { LINEAR } from './commands/cli/builtin/linear/index.ts'
|
||||
export { GIT } from './commands/cli/builtin/git/index.ts'
|
||||
export { GH } from './commands/cli/builtin/gh/index.ts'
|
||||
export { nodeHelp, walk } from './commands/cli/walk.ts'
|
||||
export { CLIRegistry } from './workspace/cli/registry.ts'
|
||||
export type { CLIInstall } from './workspace/cli/types.ts'
|
||||
@@ -943,6 +944,20 @@ export {
|
||||
export { GitHubAccessor, type GitHubResourceLike } from './accessor/github.ts'
|
||||
export { GITHUB_COMMANDS } from './commands/builtin/github/index.ts'
|
||||
export { GITHUB_OPS } from './ops/github/index.ts'
|
||||
export {
|
||||
type GhConfig,
|
||||
type GhConfigRedacted,
|
||||
GhConfigSchema,
|
||||
normalizeGhConfig,
|
||||
redactGhConfig,
|
||||
} from './core/github/config.ts'
|
||||
export {
|
||||
forkRepo as githubForkRepo,
|
||||
login as githubLogin,
|
||||
parseRepo as githubParseRepo,
|
||||
renameRepo as githubRenameRepo,
|
||||
viewRepo as githubViewRepo,
|
||||
} from './core/github/repo.ts'
|
||||
export { read as githubRead, stream as githubStream } from './core/github/read.ts'
|
||||
export { readdir as githubReaddir } from './core/github/readdir.ts'
|
||||
export {
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface IOResultInit {
|
||||
writes?: Record<string, ByteSource>
|
||||
cache?: string[]
|
||||
producer?: Producer | null
|
||||
mutated?: boolean | null
|
||||
}
|
||||
|
||||
export class IOResult {
|
||||
@@ -84,6 +85,12 @@ export class IOResult {
|
||||
// policy layer as context. Facts ride the envelope, policy
|
||||
// decisions never do.
|
||||
producer: Producer | null
|
||||
// Whether this run changed service state, when only the handler can
|
||||
// tell. A CLI leaf declares `write` statically because for almost every
|
||||
// verb it is static, but `gh api` carries its method on the line, so a
|
||||
// plain `gh api /user` is a read through a leaf that is declared
|
||||
// writable. null leaves the spec's answer standing.
|
||||
mutated: boolean | null
|
||||
streamSource: IOResult | null
|
||||
|
||||
constructor(init: IOResultInit = {}) {
|
||||
@@ -94,6 +101,7 @@ export class IOResult {
|
||||
this.writes = init.writes ?? {}
|
||||
this.cache = init.cache ?? []
|
||||
this.producer = init.producer ?? null
|
||||
this.mutated = init.mutated ?? null
|
||||
this.streamSource = null
|
||||
}
|
||||
|
||||
|
||||
@@ -359,7 +359,13 @@ export async function handleCli(
|
||||
// matters. Dropping the service's listings is what lets the agent's next
|
||||
// `ls` see what it just made, and dropping its cached bodies is what lets
|
||||
// the next `cat` see an edit rather than the pre-write content.
|
||||
if (leaf.write && dropCaches !== null) await dropCaches()
|
||||
//
|
||||
// The spec's `write` is the static answer, which is the only one most
|
||||
// verbs have; a handler that knows better says so on its result, so a
|
||||
// read-only `gh api` does not expire every github mount.
|
||||
const mutated = io.mutated ?? leaf.write
|
||||
if (mutated && dropCaches !== null) await dropCaches()
|
||||
|
||||
io.producer = { command: prog, prefixes: [], declared: leaf.limit ?? null }
|
||||
|
||||
if (warnings.length > 0) {
|
||||
|
||||
@@ -174,7 +174,11 @@ export async function dropServiceCaches(
|
||||
const wanted = new Set<string>(serves)
|
||||
for (const mount of registry.allMounts()) {
|
||||
if (!wanted.has(mount.resource.kind)) continue
|
||||
await mount.resource.index?.clear()
|
||||
// Invalidate rather than clear: a cleared index reads exactly like one
|
||||
// that was never filled, so a backend whose index *is* its listing
|
||||
// (github seeds the whole tree once) cannot tell the drop from an empty
|
||||
// repository. Expiring keeps that distinction and the next read refetches.
|
||||
await mount.resource.index?.invalidate()
|
||||
await mount.cacheManager?.dropPrefix()
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+132
-1
@@ -429,6 +429,18 @@ importers:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.0.0
|
||||
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
|
||||
'@octokit/core':
|
||||
specifier: ^7.0.7
|
||||
version: 7.0.7
|
||||
'@octokit/plugin-retry':
|
||||
specifier: ^8.1.1
|
||||
version: 8.1.1(@octokit/core@7.0.7)
|
||||
'@octokit/plugin-throttling':
|
||||
specifier: ^11.0.5
|
||||
version: 11.0.5(@octokit/core@7.0.7)
|
||||
'@octokit/request-error':
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.1
|
||||
bson:
|
||||
specifier: ^6.10.4
|
||||
version: 6.10.4
|
||||
@@ -1903,6 +1915,48 @@ packages:
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@octokit/auth-token@6.0.0':
|
||||
resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/core@7.0.7':
|
||||
resolution: {integrity: sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/endpoint@11.0.4':
|
||||
resolution: {integrity: sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/graphql@9.0.4':
|
||||
resolution: {integrity: sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/openapi-types@28.0.0':
|
||||
resolution: {integrity: sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==}
|
||||
|
||||
'@octokit/plugin-retry@8.1.1':
|
||||
resolution: {integrity: sha512-VCVvZ/R1+u3WuiBWpNavZ0mY4aaJNAsENrpBP9aLSR2QyOpQgd7DhM5j4AW7z4MQpnJYgwBPf0XqPQoNBRdQwg==}
|
||||
engines: {node: '>= 20'}
|
||||
peerDependencies:
|
||||
'@octokit/core': '>=7'
|
||||
|
||||
'@octokit/plugin-throttling@11.0.5':
|
||||
resolution: {integrity: sha512-LIdrkrUv+DWbKeg/49rGuFJ3SU0d3hUS+B4MhNZLepBoNUFXms8Ic9edJjrlx+zycqJHjrMRudVpVb/bAXM2Lw==}
|
||||
engines: {node: '>= 20'}
|
||||
peerDependencies:
|
||||
'@octokit/core': ^7.0.0
|
||||
|
||||
'@octokit/request-error@7.1.1':
|
||||
resolution: {integrity: sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/request@10.0.13':
|
||||
resolution: {integrity: sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@octokit/types@17.0.0':
|
||||
resolution: {integrity: sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==}
|
||||
|
||||
'@openai/agents-core@0.13.5':
|
||||
resolution: {integrity: sha512-RI9OwHG94c6ZTLNeEB7mfIpHbncgVxu4YElAmCAhq04EqLPzsLyouWhDgli8wZL/IhN/cYTaBwHvxktFzEbeyQ==}
|
||||
peerDependencies:
|
||||
@@ -3554,6 +3608,9 @@ packages:
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
||||
|
||||
before-after-hook@4.0.0:
|
||||
resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
|
||||
|
||||
better-path-resolve@1.0.0:
|
||||
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3565,6 +3622,9 @@ packages:
|
||||
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
bottleneck@2.19.5:
|
||||
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
|
||||
|
||||
bowser@2.14.1:
|
||||
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||
|
||||
@@ -4743,6 +4803,9 @@ packages:
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
json-with-bigint@3.5.10:
|
||||
resolution: {integrity: sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==}
|
||||
|
||||
jsonfile@4.0.0:
|
||||
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
|
||||
|
||||
@@ -6348,6 +6411,9 @@ packages:
|
||||
unist-util-visit@5.1.0:
|
||||
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
|
||||
|
||||
universal-user-agent@7.0.3:
|
||||
resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==}
|
||||
|
||||
universalify@0.1.2:
|
||||
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
@@ -8443,6 +8509,61 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@octokit/auth-token@6.0.0': {}
|
||||
|
||||
'@octokit/core@7.0.7':
|
||||
dependencies:
|
||||
'@octokit/auth-token': 6.0.0
|
||||
'@octokit/graphql': 9.0.4
|
||||
'@octokit/request': 10.0.13
|
||||
'@octokit/request-error': 7.1.1
|
||||
'@octokit/types': 17.0.0
|
||||
before-after-hook: 4.0.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/endpoint@11.0.4':
|
||||
dependencies:
|
||||
'@octokit/types': 17.0.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/graphql@9.0.4':
|
||||
dependencies:
|
||||
'@octokit/request': 10.0.13
|
||||
'@octokit/types': 17.0.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/openapi-types@28.0.0': {}
|
||||
|
||||
'@octokit/plugin-retry@8.1.1(@octokit/core@7.0.7)':
|
||||
dependencies:
|
||||
'@octokit/core': 7.0.7
|
||||
'@octokit/request-error': 7.1.1
|
||||
'@octokit/types': 17.0.0
|
||||
bottleneck: 2.19.5
|
||||
|
||||
'@octokit/plugin-throttling@11.0.5(@octokit/core@7.0.7)':
|
||||
dependencies:
|
||||
'@octokit/core': 7.0.7
|
||||
'@octokit/types': 17.0.0
|
||||
bottleneck: 2.19.5
|
||||
|
||||
'@octokit/request-error@7.1.1':
|
||||
dependencies:
|
||||
'@octokit/types': 17.0.0
|
||||
|
||||
'@octokit/request@10.0.13':
|
||||
dependencies:
|
||||
'@octokit/endpoint': 11.0.4
|
||||
'@octokit/request-error': 7.1.1
|
||||
'@octokit/types': 17.0.0
|
||||
content-type: 2.0.0
|
||||
json-with-bigint: 3.5.10
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/types@17.0.0':
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 28.0.0
|
||||
|
||||
'@openai/agents-core@0.13.5(@aws-sdk/credential-provider-node@3.972.70)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.6.6)(ws@8.21.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -10204,7 +10325,7 @@ snapshots:
|
||||
|
||||
axios@1.18.1:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0(debug@4.4.3)
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.6
|
||||
https-proxy-agent: 5.0.1
|
||||
proxy-from-env: 2.1.0
|
||||
@@ -10258,6 +10379,8 @@ snapshots:
|
||||
dependencies:
|
||||
tweetnacl: 0.14.5
|
||||
|
||||
before-after-hook@4.0.0: {}
|
||||
|
||||
better-path-resolve@1.0.0:
|
||||
dependencies:
|
||||
is-windows: 1.0.2
|
||||
@@ -10278,6 +10401,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
bottleneck@2.19.5: {}
|
||||
|
||||
bowser@2.14.1: {}
|
||||
|
||||
brace-expansion@1.1.16:
|
||||
@@ -11098,6 +11223,8 @@ snapshots:
|
||||
|
||||
flatted@3.4.2: {}
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
|
||||
follow-redirects@1.16.0(debug@4.4.3):
|
||||
optionalDependencies:
|
||||
debug: 4.4.3
|
||||
@@ -11518,6 +11645,8 @@ snapshots:
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json-with-bigint@3.5.10: {}
|
||||
|
||||
jsonfile@4.0.0:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -13372,6 +13501,8 @@ snapshots:
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
universal-user-agent@7.0.3: {}
|
||||
|
||||
universalify@0.1.2: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
Reference in New Issue
Block a user