@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
308 lines • 13.3 kB
JavaScript
import { CommandSpec } from "../../../spec/types.js";
import { compareCodePoints } from "../../../../utils/sort.js";
import { csvValues, ghBool, jsonFields, textOut, typedOut } from "./accessor.js";
import { SEARCH_ALIASES, SEARCH_BOOLEAN, SEARCH_FIELDS, SEARCH_FLAGS, SEARCH_MULTIPLE, SEARCH_SHAPES, SEARCH_SORTS, } from "./constants.js";
import { renderTemplate } from "./template.js";
import { CLIHandler } from "../../types.js";
import { UsageError } from "../../../errors.js";
import { FlagView } from "../../../spec/flag_view.js";
import { Argument } from "../../../spec/types.js";
import { GitHubApiError } from "../../../../core/github/client.js";
import { IOResult } from "../../../../io/types.js";
import { search } from "../../../../core/github/search.js";
function quote(value) {
return /[\s"]/.test(value) ? JSON.stringify(value) : value;
}
function boolean(fl, name) {
return ghBool(fl, name.replaceAll('-', '_'));
}
function query(kind, words, fl) {
const qualifiers = {};
for (const name of SEARCH_FLAGS[kind] ?? []) {
const value = fl.raw(name.replaceAll('-', '_'));
if (value === undefined ||
['app', 'include-prs', 'locked', 'merged'].includes(name) ||
name.startsWith('no-'))
continue;
let key = SEARCH_ALIASES[name] ?? name;
if (name === 'review-requested' && typeof value === 'string' && value.includes('/'))
key = 'team-review-requested';
const values = SEARCH_BOOLEAN.includes(name)
? [String(boolean(fl, name))]
: SEARCH_MULTIPLE.includes(name)
? csvValues(fl.asList(name.replaceAll('-', '_')))
: [fl.asStr(name.replaceAll('-', '_')) ?? ''];
(qualifiers[key] ??= []).push(...values.filter(Boolean));
}
if (kind === 'issues' || kind === 'prs') {
if (kind === 'prs' || !boolean(fl, 'include_prs'))
qualifiers.type = [kind === 'prs' ? 'pr' : 'issue'];
if (fl.asStr('app') !== undefined) {
if (fl.asStr('author') !== undefined)
throw new UsageError('specify only `--author` or `--app`', 1);
qualifiers.author = [`app/${fl.asStr('app') ?? ''}`];
}
for (const name of kind === 'prs' ? ['locked', 'merged'] : ['locked']) {
if (fl.raw(name) !== undefined)
(qualifiers.is ??= []).push(boolean(fl, name) ? name : `un${name}`);
}
qualifiers.no = ['assignee', 'label', 'milestone', 'project'].filter((name) => boolean(fl, `no_${name}`));
}
const keywords = words.map((word) => {
const at = word.indexOf(':');
return at < 0 ? quote(word) : `${word.slice(0, at)}:${quote(word.slice(at + 1))}`;
});
return [
...keywords,
...Object.keys(qualifiers)
.flatMap((key) => (qualifiers[key] ?? []).map((value) => `${key}:${quote(value)}`))
.sort(compareCodePoints),
].join(' ');
}
function option(name) {
const choices = {
state: ['open', 'closed'],
'include-forks': ['false', 'true', 'only'],
checks: ['pending', 'success', 'failure'],
review: ['none', 'required', 'approved', 'changes_requested'],
};
const shorts = { repo: '-R', base: '-B', head: '-H' };
return new Argument([...(shorts[name] === undefined ? [] : [shorts[name]]), `--${name}`], {
action: SEARCH_MULTIPLE.includes(name) ? 'append' : 'store',
nargs: SEARCH_BOOLEAN.includes(name) ? '?' : null,
attachedOnly: SEARCH_BOOLEAN.includes(name),
choices: SEARCH_BOOLEAN.includes(name) ? ['true', 'false'] : (choices[name] ?? []),
});
}
export function searchSpec() {
return new CommandSpec({
name: 'search',
description: 'Search GitHub',
subcommands: Object.entries(SEARCH_FLAGS).map(([kind, names]) => new CommandSpec({
name: kind,
description: `Search for ${kind}`,
arguments: [
...names.map(option),
new Argument('--json'),
new Argument(['-q', '--jq']),
new Argument(['-t', '--template']),
new Argument(['-L', '--limit'], { type: 'int', default: '30' }),
...(SEARCH_SORTS[kind] === undefined
? []
: [
new Argument('--sort', { choices: SEARCH_SORTS[kind] }),
new Argument('--order', { choices: ['asc', 'desc'] }),
]),
new Argument('QUERY', { nargs: '*' }),
],
})),
});
}
export function searchHandlers() {
return Object.fromEntries(Object.keys(SEARCH_FLAGS).map((kind) => [
`search ${kind}`,
new CLIHandler({
fn: (accessor, paths, texts, ctx) => searchCmd(kind, accessor, paths, texts, ctx),
}),
]));
}
async function searchCmd(kind, accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags, ctx.spec);
const fields = jsonFields(fl, SEARCH_FIELDS[kind] ?? []);
const limit = fl.asInt('limit');
if (limit === undefined || limit < 1 || limit > 1000)
throw new UsageError('`--limit` must be between 1 and 1000', 1);
if (texts.length === 0 && (ctx.argv ?? []).length <= 2)
throw new UsageError('specify search keywords or flags', 1);
if (fields === null && (fl.asStr('jq') !== undefined || fl.asStr('template') !== undefined))
throw new UsageError('cannot use `--jq` or `--template` without `--json`', 1);
if (fl.asStr('jq') !== undefined && fl.asStr('template') !== undefined)
throw new UsageError('cannot use `--jq` and `--template` together', 1);
const expression = query(kind, texts, fl);
let values;
try {
values = await search(accessor.transport, { repos: 'repositories', prs: 'issues' }[kind] ?? kind, expression, limit, SEARCH_SORTS[kind] ? fl.asStr('sort') : undefined, SEARCH_SORTS[kind] ? fl.asStr('order') : undefined);
}
catch (error) {
if (!(error instanceof GitHubApiError))
throw error;
return [
null,
new IOResult({
exitCode: 1,
stderr: new TextEncoder().encode(`${searchError(error, expression)}\n`),
}),
];
}
const rows = values.map((value) => exported(kind, value));
const template = fl.asStr('template');
if (template !== undefined)
return textOut(renderTemplate(template, rows.map((row) => Object.fromEntries((fields ?? []).map((key) => [key, row[key] ?? null])))));
return typedOut(rows, fl, human(kind, rows, values, kind === 'issues' && boolean(fl, 'include_prs')), SEARCH_FIELDS[kind] ?? [], ctx);
}
/**
* How gh search words a failed search, its `httpError.Error`.
*
* A 422 naming its errors says the query is invalid, with the first reason;
* anything else is the status, GitHub's message (the status line for a body
* that is not JSON) and the request URL.
*/
function searchError(error, expression) {
const { data } = error;
const body = typeof data === 'object' && data !== null && !Array.isArray(data) ? record(data) : null;
const errors = body?.errors;
if (error.status === 422 && Array.isArray(errors) && errors.length > 0) {
const reason = record(errors[0]).message;
const quoted = JSON.stringify(expression.trim());
return `Invalid search query ${quoted}.\n${typeof reason === 'string' ? reason : ''}`;
}
let message = `${String(error.status)} ${error.message}`;
if (body !== null)
message = typeof body.message === 'string' ? body.message : '';
return `HTTP ${String(error.status)}: ${message} (${error.url})`;
}
function record(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
}
function shape(kind, value) {
if (kind.startsWith('*'))
return value == null ? null : shape(kind.slice(1), value);
if (kind.startsWith('[]'))
return Array.isArray(value) ? value.map((item) => shape(kind.slice(2), item)) : null;
if (kind === 'time.Time')
return value ?? '0001-01-01T00:00:00Z';
if (kind === 'string')
return value ?? '';
if (kind === 'bool')
return Boolean(value);
if (kind === 'int')
return value ?? 0;
const row = record(value);
return Object.fromEntries((SEARCH_SHAPES[kind] ?? []).map(([name, key, type]) => [name, shape(type, row[key])]));
}
function user(value) {
const row = record(value), bot = !row.node_id;
return {
id: row.node_id ?? '',
login: `${bot ? 'app/' : ''}${str(row.login)}`,
type: row.type ?? '',
url: row.html_url ?? '',
is_bot: bot,
};
}
function exported(kind, value) {
const raw = record(value);
const row = shape({
repos: 'Repository',
issues: 'Issue',
prs: 'Issue',
code: 'Code',
commits: 'Commit',
}[kind] ?? '', raw);
if (kind === 'repos')
row.owner = user(raw.owner);
else if (kind === 'issues' || kind === 'prs') {
row.author = user(raw.user);
row.assignees = (raw.assignees ?? []).map(user);
row.labels ??= [];
const pull = record(raw.pull_request);
row.isPullRequest = Boolean(pull.html_url);
row.state = pull.merged_at ? 'merged' : (raw.state ?? '');
const parts = str(raw.repository_url).replace(/\/$/, '').split('/');
row.repository = { name: parts.at(-1) ?? '', nameWithOwner: parts.slice(-2).join('/') };
}
else if (kind === 'code') {
const repo = record(raw.repository);
row.repository = {
id: repo.node_id ?? '',
nameWithOwner: repo.full_name ?? '',
url: repo.html_url ?? '',
isPrivate: Boolean(repo.private),
isFork: Boolean(repo.fork),
};
row.textMatches = (raw.text_matches ?? []).map((m) => ({
fragment: m.fragment ?? '',
matches: m.matches ?? null,
type: m.object_type ?? '',
property: m.property ?? '',
}));
}
else if (kind === 'commits') {
row.author = user(raw.author);
row.committer = user(raw.committer);
const info = record(raw.commit);
row.commit = {
author: shape('CommitUser', info.author),
committer: shape('CommitUser', info.committer),
comment_count: info.comment_count ?? 0,
message: info.message ?? '',
tree: shape('Tree', info.tree),
};
row.parents ??= [];
const repo = exported('repos', raw.repository);
row.repository = Object.fromEntries(['description', 'fullName', 'name', 'id', 'isFork', 'isPrivate', 'owner', 'url'].map((key) => [key, repo[key] ?? null]));
}
return row;
}
function str(value) {
return typeof value === 'string' ? value : '';
}
function records(value) {
return Array.isArray(value) ? value.map(record) : [];
}
function human(kind, rows, values, both) {
const lines = [];
const clean = (value) => str(value).trim().split(/\s+/).join(' ');
rows.forEach((row, i) => {
const raw = record(values[i]), repo = record(row.repository);
let cells;
if (kind === 'issues' || kind === 'prs')
cells = [
...(both ? [row.isPullRequest ? 'pr' : 'issue'] : []),
str(repo.nameWithOwner),
String(typeof row.number === 'number' ? row.number : 0),
str(row.state),
clean(row.title),
records(row.labels)
.map((label) => str(label.name))
.join(', '),
str(row.updatedAt),
];
else if (kind === 'repos') {
const tags = [
str(row.visibility) || (row.isPrivate ? 'private' : 'public'),
...(row.isFork ? ['fork'] : []),
...(row.isArchived ? ['archived'] : []),
];
cells = [str(row.fullName), clean(row.description), tags.join(', '), str(row.updatedAt)];
}
else if (kind === 'commits') {
const info = record(row.commit);
cells = [
str(repo.fullName),
str(row.sha),
clean(info.message),
str(record(raw.author).login),
str(record(info.author).date),
];
}
else {
for (const match of records(row.textMatches)) {
let offset = 0;
for (const line of str(match.fragment).split('\n')) {
const end = offset + new TextEncoder().encode(line).length;
if (records(match.matches).some((m) => Array.isArray(m.indices) &&
typeof m.indices[0] === 'number' &&
m.indices[0] >= offset &&
m.indices[0] < end))
lines.push(`${str(repo.nameWithOwner)}:${str(row.path)}: ${line.trim()}\n`);
offset = end + 1;
}
}
return;
}
lines.push(`${cells.join('\t')}\n`);
});
return lines.join('');
}
//# sourceMappingURL=search.js.map