@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
232 lines • 11.2 kB
JavaScript
// ========= 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 { commentIssue, createIssue, editIssue, getIssue, issueComments, issueFields, listIssueFields, listIssues, } from "../../../../core/github/issue.js";
import { IOResult } from "../../../../io/types.js";
import { FlagView } from "../../../spec/flag_view.js";
import { bodyValue, camel, csvValues, ghBool, jsonFields, listLimit, repoFor, repoNumber, textOut, textValue, typedOut, } from "./accessor.js";
import { exportedNode, plain, readRest, references, selection, SHARED_FIELDS, } from "./fields.js";
/**
* Every field `gh issue view --json` and `gh issue list --json` accept in
* gh 2.85: the ones pull requests share, and the ones only an issue has.
*/
const ISSUE_FIELD_TABLE = new Map([
...SHARED_FIELDS,
plain('isPinned', 'bool'),
plain('stateReason', 'string'),
references('closedByPullRequestsReferences'),
]);
export const ISSUE_FIELDS = [...ISSUE_FIELD_TABLE.keys()];
// The fields a pull request has no answer for, which gh leaves out of the
// pull request half of its query, so they print at their zero for one.
const ISSUE_ONLY = new Set(['isPinned', 'stateReason', 'closedByPullRequestsReferences']);
// The `--state` spellings as the issue states gh lists for each.
const STATES = {
open: ['OPEN'],
closed: ['CLOSED'],
all: ['OPEN', 'CLOSED'],
};
/**
* One issue as `gh issue view --json` reads it. gh asks for the number as an
* issue or a pull request, so a pull request's number answers too. It adds
* the `id` its follow-ups read, and the `number` when it reads project items
* apart; every connection it pages is then read to its end, through the half
* of the query that matches what the number turned out to be.
*/
async function viewedIssue(transport, ref, number, fields) {
const names = [...fields, 'id', ...(fields.includes('projectItems') ? ['number'] : [])];
const node = await issueFields(transport, ref, number, {
issue: selection(ISSUE_FIELD_TABLE, names, true),
pull: selection(ISSUE_FIELD_TABLE, names.filter((name) => !ISSUE_ONLY.has(name)), true),
});
const pull = node.__typename === 'PullRequest';
return readRest(ISSUE_FIELD_TABLE, node, fields, (select, cursor) => issueFields(transport, ref, number, pull ? { issue: '', pull: select } : { issue: select, pull: '' }, cursor));
}
function issue(value) {
const row = camel(value);
const result = row !== null && typeof row === 'object' ? row : {};
result.closed = textValue(result.state).toLowerCase() === 'closed';
return result;
}
function names(value, key) {
if (!Array.isArray(value))
return [];
return value
.filter((item) => item !== null && typeof item === 'object')
.map((item) => textValue(item[key] ?? item.name))
.filter(Boolean);
}
function labels(row) {
return names(row.labels, 'name').join(', ');
}
function listText(rows) {
return rows
.map((row) => `${textValue(row.number)}\t${textValue(row.state).toUpperCase()}\t${textValue(row.title)}\t${labels(row)}\n`)
.join('');
}
function viewText(row) {
const author = row.author;
return `title:\t${textValue(row.title)}\nstate:\t${textValue(row.state).toUpperCase()}\nauthor:\t${textValue(author?.login)}\nlabels:\t${labels(row)}\n--\n${textValue(row.body)}\n`;
}
function target(accessor, texts, fl) {
return repoNumber(accessor, fl, texts[0], 'issue', 'issues');
}
/**
* `gh issue list`. With `--json` it asks GraphQL for exactly the fields
* named, the way gh's IssueList does, so every field gh accepts is answered
* in gh's own shape; the text view reads the REST listing.
*/
export async function listCmd(accessor, _paths, _texts, ctx) {
const fl = new FlagView(ctx.flags);
const state = fl.asStr('state') ?? 'open';
const assignee = fl.asNonEmpty('assignee');
const author = fl.asNonEmpty('author');
const labelValues = csvValues(fl.asList('label'));
const fields = jsonFields(fl, ISSUE_FIELDS);
if (fields !== null) {
const rows = await listIssueFields(accessor.transport, repoFor(accessor, fl), { states: STATES[state] ?? ['OPEN'], assignee, author, labels: labelValues }, listLimit(fl, 30), selection(ISSUE_FIELD_TABLE, fields, false));
return typedOut(rows.map((node) => exportedNode(ISSUE_FIELD_TABLE, node, fields)), fl, '', ISSUE_FIELDS, ctx);
}
const params = { state };
if (assignee !== undefined)
params.assignee = assignee;
if (author !== undefined)
params.creator = author;
if (labelValues.length > 0)
params.labels = labelValues.join(',');
const values = await listIssues(accessor.transport, repoFor(accessor, fl), params, listLimit(fl, 30));
const rows = values.map(issue);
return typedOut(rows, fl, listText(rows), ISSUE_FIELDS, ctx);
}
/**
* `gh issue view`. With `--json` it reads the fields named over GraphQL, as
* gh does (see viewedIssue); the text view reads the REST object, and `-c`
* its comments.
*/
export async function viewCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const [ref, number] = target(accessor, texts, fl);
const fields = jsonFields(fl, ISSUE_FIELDS);
if (fields !== null) {
const node = await viewedIssue(accessor.transport, ref, number, fields);
return typedOut(exportedNode(ISSUE_FIELD_TABLE, node, fields), fl, '', ISSUE_FIELDS, ctx);
}
const row = issue(await getIssue(accessor.transport, ref, number));
const comments = await commentsFor(accessor, fl, ref, number);
return typedOut(row, fl, ghBool(fl, 'comments') ? commentsText(comments ?? []) : viewText(row), ISSUE_FIELDS, ctx);
}
export async function createCmd(accessor, _paths, _texts, ctx) {
const fl = new FlagView(ctx.flags);
const title = fl.asStr('title');
if (title === undefined || title === '')
throw new Error('--title is required in noninteractive mode');
const body = { title, body: (await bodyValue(ctx, fl)) ?? '' };
const labelValues = csvValues(fl.asList('label'));
const assignees = csvValues(fl.asList('assignee'));
if (labelValues.length > 0)
body.labels = labelValues;
if (assignees.length > 0)
body.assignees = assignees;
const created = issue(await createIssue(accessor.transport, repoFor(accessor, fl), body));
return textOut(`${textValue(created.url)}\n`);
}
export async function editCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const transport = accessor.transport;
const [ref, number] = target(accessor, texts, fl);
const body = {};
const title = fl.asStr('title');
const text = await bodyValue(ctx, fl);
if (title !== undefined)
body.title = title;
if (text !== undefined)
body.body = text;
const additions = csvValues(fl.asList('add_label'));
const removals = new Set(csvValues(fl.asList('remove_label')));
const addAssignees = csvValues(fl.asList('add_assignee'));
const removeAssignees = new Set(csvValues(fl.asList('remove_assignee')));
if (additions.length > 0 ||
removals.size > 0 ||
addAssignees.length > 0 ||
removeAssignees.size > 0) {
const current = issue(await getIssue(transport, ref, number));
if (additions.length > 0 || removals.size > 0) {
body.labels = [
...new Set([
...names(current.labels, 'name').filter((name) => !removals.has(name)),
...additions,
]),
];
}
if (addAssignees.length > 0 || removeAssignees.size > 0) {
body.assignees = [
...new Set([
...names(current.assignees, 'login').filter((name) => !removeAssignees.has(name)),
...addAssignees,
]),
];
}
}
if (Object.keys(body).length === 0)
throw new Error('no issue fields to edit');
const edited = issue(await editIssue(transport, ref, number, body));
return textOut(`${textValue(edited.url)}\n`);
}
export async function closeCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const [ref, number] = target(accessor, texts, fl);
const edited = issue(await editIssue(accessor.transport, ref, number, { state: 'closed' }));
return textOut(`✓ Closed issue ${ref.owner}/${ref.repo}#${String(number)} (${textValue(edited.title)})\n`);
}
export async function reopenCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const [ref, number] = target(accessor, texts, fl);
const transport = accessor.transport;
const row = issue(await getIssue(transport, ref, number));
const name = `${ref.owner}/${ref.repo}#${String(number)} (${textValue(row.title)})`;
if (row.closed !== true) {
return [
new Uint8Array(0),
new IOResult({ stderr: new TextEncoder().encode(`! Issue ${name} is already open\n`) }),
];
}
const comment = fl.asStr('comment');
if (comment !== undefined && comment !== '')
await commentIssue(transport, ref, number, comment);
await editIssue(transport, ref, number, { state: 'open' });
return textOut(`✓ Reopened issue ${name}\n`);
}
export async function commentCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const body = await bodyValue(ctx, fl, { required: true });
const [ref, number] = target(accessor, texts, fl);
const comment = issue(await commentIssue(accessor.transport, ref, number, body ?? ''));
return textOut(`${textValue(comment.url)}\n`);
}
export async function commentsFor(accessor, fl, ref, number) {
if (!ghBool(fl, 'comments'))
return null;
const rows = await issueComments(accessor.transport, ref, number);
return rows.map((row) => ({
...row,
author: row.author ?? { login: '' },
minimizedReason: row.minimizedReason ?? '',
reactionGroups: row.reactionGroups.filter((group) => group.users.totalCount > 0),
}));
}
export function commentsText(rows) {
return rows
.map((row) => `author:\t${textValue(row.author?.login)}\nassociation:\t${textValue(row.authorAssociation).toLowerCase()}\nedited:\t${String(row.includesCreatedEdit)}\nstatus:\t${row.isMinimized ? textValue(row.minimizedReason).toLowerCase() : 'none'}\n--\n${textValue(row.body)}\n--\n`)
.join('');
}
//# sourceMappingURL=issue.js.map