UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

233 lines • 10.2 kB
// ========= 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. ========= // Repository arguments are read as go-gh v2.13.0 reads them (MIT; see // licenses/third_party/go-gh.txt). import { decodeBase64 } from "../../utils/base64.js"; import { stripSlash } from "../../utils/slash.js"; import { GitHubApiError } from "./client.js"; import { GRAPHQL_PATH } from "./constants.js"; import { githubPages } from "./paginate.js"; // go-gh's IsURL: a word that starts `git@` or with a scheme a git remote uses // names a repository by URL rather than as `[HOST/]OWNER/REPO`. const URL_PREFIXES = ['git@', 'ssh:', 'git+ssh:', 'git:', 'http:', 'git+https:', 'https:']; // The schemes go-gh leaves alone before it reads an scp-style `host:path`. const PROTOCOLS = [...URL_PREFIXES.slice(1), 'ftp:', 'ftps:', 'file:']; /** * A repository URL as go-gh's ParseURL reads it: `git@HOST:OWNER/REPO.git` is * scp syntax for `ssh://`, and a URL with no host is refused. */ function urlOf(spec) { let raw = spec; if (!PROTOCOLS.some((prefix) => raw.startsWith(prefix)) && raw.includes(':') && !raw.includes('\\')) raw = `ssh://${raw.replace(':', '/')}`; let url; try { url = new URL(raw); } catch { throw new Error('no hostname detected'); } if (url.hostname === '') throw new Error('no hostname detected'); return url; } /** The host a repository argument names, null for `OWNER/REPO`. */ export function repoHost(spec) { if (URL_PREFIXES.some((prefix) => spec.startsWith(prefix))) return urlOf(spec).hostname; const parts = spec.split('/'); return parts.length === 3 ? (parts[0] ?? '').toLowerCase() : null; } /** * A repository named by URL, read as go-gh's ParseURL and RepoInfoFromURL * read it: the path must be exactly two segments once its slashes are * trimmed, and `.git` comes off the name. */ function repoFromUrl(spec) { const url = urlOf(spec); let path = url.pathname; if (url.protocol === 'ssh:' && path.startsWith('//')) path = path.slice(1); const parts = stripSlash(path).split('/'); const [owner, name] = parts; if (parts.length !== 2 || owner === undefined || name === undefined) { throw new Error(`invalid path: ${path}`); } return { owner, repo: name.replace(/\.git$/, '') }; } /** * The repository a word names, as gh reads it: a URL (`https://HOST/OWNER/REPO`, * `git@HOST:OWNER/REPO.git`) or `[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) { if (URL_PREFIXES.some((prefix) => spec.startsWith(prefix))) return repoFromUrl(spec); const parts = spec.split('/'); const owner = parts[parts.length - 2]; const repo = parts[parts.length - 1]; // One more segment is a host; two is not a repository any spelling reaches. if ((parts.length !== 2 && parts.length !== 3) || parts.some((part) => part === '') || owner === undefined || repo === undefined) { throw new Error(`expected the "[HOST/]OWNER/REPO" format, got "${spec}"`); } return { owner, repo }; } export async function login(transport) { const me = (await transport.get('/user')); return me.login ?? ''; } export function viewRepo(transport, ref) { return transport.get(`/repos/${ref.owner}/${ref.repo}`); } /** * Run one GraphQL query and return its data, refusing the way gh does. * * gh names each error with the path of the field that raised it and joins * them: `GraphQL: Could not resolve to a Repository with the name 'o/r'. * (repository)`. */ export async function graphqlData(transport, query, variables) { const response = (await transport.request('POST', GRAPHQL_PATH, { query, variables })); if (response.errors !== undefined && response.errors.length > 0) { const messages = response.errors.map((error) => { const path = (error.path ?? []).map(String).join('.'); return path === '' ? (error.message ?? '') : `${error.message ?? ''} (${path})`; }); throw new Error(`GraphQL: ${messages.join(', ')}`); } return response.data ?? {}; } /** * The selected fields of one repository, over GraphQL, as gh reads them for * `repo view --json`: one query naming only what was asked for. * * Args: * transport (GitHubTransport): the API client. * ref (RepoRef): the repository. * selection (string): the GraphQL selection inside `repository { }`. */ export async function repositoryFields(transport, ref, selection) { const data = await graphqlData(transport, `query RepositoryInfo($owner: String!, $name: String!) {\n` + ` repository(owner: $owner, name: $name) {${selection}}\n }`, { owner: ref.owner, name: ref.repo }); return (data.repository ?? {}); } /** * The selected fields of an owner's repositories, over GraphQL, as gh reads * them for `repo list --json`: the owner's own, most recently pushed first, a * page of up to 100 at a time until `limit`. No owner means the viewer. * * Args: * transport (GitHubTransport): the API client. * owner (string | undefined): the user or organization, or the viewer. * limit (number): how many repositories at most. * selection (string): the GraphQL selection for each repository. */ export async function listRepositoryFields(transport, owner, limit, selection) { const head = owner === undefined ? 'query RepositoryList($perPage:Int!,$endCursor:String,$privacy:RepositoryPrivacy,' + '$fork:Boolean) {\n repositoryOwner: viewer {' : 'query RepositoryList($perPage:Int!,$endCursor:String,$privacy:RepositoryPrivacy,' + '$fork:Boolean,$owner:String!) {\n repositoryOwner(login: $owner) {'; const query = `${head}\n login\n repositories(first: $perPage, after: $endCursor, ` + 'privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: ' + `PUSHED_AT, direction: DESC }) {\n nodes{${selection}}\n totalCount\n` + ' pageInfo{hasNextPage,endCursor}\n }\n }\n }'; const rows = []; let cursor = null; while (rows.length < limit) { const variables = { perPage: Math.min(limit, 100) }; if (owner !== undefined) variables.owner = owner; if (cursor !== null) variables.endCursor = cursor; const data = await graphqlData(transport, query, variables); const page = data.repositoryOwner?.repositories; for (const item of page?.nodes ?? []) rows.push(item); const next = page?.pageInfo?.endCursor ?? null; if (page?.pageInfo?.hasNextPage !== true || next === null || next === cursor) break; cursor = next; } return rows.slice(0, limit); } /** * The repository's README as text, or null when it has none. */ export async function readReadme(transport, ref) { let data; 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?.content; if (typeof content !== 'string') return null; return new TextDecoder().decode(decodeBase64(content)); } /** Fork a repository: `body` carries the request's `name`, `organization` and `default_branch_only`. */ export function forkRepo(transport, ref, body = {}) { return transport.request('POST', `/repos/${ref.owner}/${ref.repo}/forks`, body); } /** Change a repository's settings: the one `PATCH` `gh repo edit` sends. */ export function editRepo(transport, ref, body) { return transport.request('PATCH', `/repos/${ref.owner}/${ref.repo}`, body); } /** A repository's topics, which GitHub keeps and replaces as one list. */ export async function repoTopics(transport, ref) { const data = (await transport.get(`/repos/${ref.owner}/${ref.repo}/topics`)); const names = data?.names; return Array.isArray(names) ? names.filter((n) => typeof n === 'string') : []; } export function setRepoTopics(transport, ref, names) { return transport.request('PUT', `/repos/${ref.owner}/${ref.repo}/topics`, { names }); } export function deleteRepo(transport, ref) { return transport.request('DELETE', `/repos/${ref.owner}/${ref.repo}`); } export function renameRepo(transport, ref, name) { return transport.request('PATCH', `/repos/${ref.owner}/${ref.repo}`, { name }); } export async function listRepos(transport, owner, limit) { let path = '/user/repos'; if (owner !== undefined) { const account = (await transport.get(`/users/${owner}`)); const prefix = account.type === 'Organization' ? 'orgs' : 'users'; path = `/${prefix}/${owner}/repos`; } return githubPages(transport, path, { params: { sort: 'pushed' }, limit }); } export async function createRepo(transport, owner, body) { const personal = owner === undefined || owner.toLowerCase() === (await login(transport)).toLowerCase(); const path = personal ? '/user/repos' : `/orgs/${owner}/repos`; return transport.request('POST', path, body); } //# sourceMappingURL=repo.js.map