@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
431 lines • 19.5 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 { commentPull, createPull, diffPull, editPull, getPull, listPullRequestFields, listPulls, mergePull, pullChecks, pullRequestFields, } from "../../../../core/github/pull.js";
import { IOResult } from "../../../../io/types.js";
import { FlagView } from "../../../spec/flag_view.js";
import { bodyValue, camel, ghBool, jsonFields, listLimit, repoFor, repoNumber, textOut, textValue, typedOut, } from "./accessor.js";
import { DIFF_HEADER, PR_INVALID_LIMIT } from "./constants.js";
import { connection, exportedNode, LOGIN, nodes, nodesOf, paged, plain, readRest, record, references, selection, SHARED_FIELDS, } from "./fields.js";
import { commentsFor, commentsText } from "./issue.js";
import { exported, orNull, pointer, struct } from "./shape.js";
const OID = pointer(['oid', 'string']);
// Its `url` is `omitempty`, and gh never asks for it, so it never prints.
const REVIEW = struct(['id', 'string'], ['author', LOGIN], ['authorAssociation', 'string'], ['body', 'string'], ['submittedAt', 'raw'], ['includesCreatedEdit', 'bool'], ['reactionGroups', 'reactions'], ['state', 'string'], ['commit', struct(['oid', 'string'])]);
const FILE = struct(['path', 'string'], ['additions', 'int'], ['deletions', 'int']);
const reviews = (after) => `reviews(first: 100${after}) {nodes {id,author{login},authorAssociation,submittedAt,body,` +
'state,commit{oid},reactionGroups{content,users{totalCount}}}' +
'pageInfo{hasNextPage,endCursor}totalCount}';
const checks = (after) => 'statusCheckRollup: commits(last: 1) {nodes {commit {statusCheckRollup ' +
`{contexts(first:100${after}) {nodes {__typename...on StatusContext {context,state,` +
'targetUrl,createdAt,description},...on CheckRun {name,checkSuite{workflowRun{workflow' +
'{name}}},status,conclusion,startedAt,completedAt,detailsUrl}},' +
'pageInfo{hasNextPage,endCursor}}}}}}';
/** The context connection of the one commit a status rollup reads. */
function contexts(node) {
const commit = nodesOf(node.statusCheckRollup)[0];
return connection(record(record(record(commit).commit).statusCheckRollup).contexts);
}
function commitsOf(node) {
return nodesOf(node.commits).map((item) => {
const commit = record(record(item).commit);
return {
authoredDate: exported(commit.authoredDate, 'time'),
authors: nodesOf(commit.authors).map((author) => {
const row = record(author);
const user = record(row.user);
return {
email: exported(row.email, 'string'),
id: exported(user.id, 'string'),
login: exported(user.login, 'string'),
name: exported(row.name, 'string'),
};
}),
committedDate: exported(commit.committedDate, 'time'),
messageBody: exported(commit.messageBody, 'string'),
messageHeadline: exported(commit.messageHeadline, 'string'),
oid: exported(commit.oid, 'string'),
};
});
}
// A status rollup with no commit behind it is null; one whose commit carries
// no rollup is an empty list, since gh builds that list before reading it.
function checksOf(node) {
if (nodesOf(node.statusCheckRollup).length === 0)
return null;
return nodesOf(contexts(node)).map((item) => {
const row = record(item);
if (row.__typename === 'CheckRun') {
const workflow = record(record(record(row.checkSuite).workflowRun).workflow);
return {
__typename: 'CheckRun',
completedAt: exported(row.completedAt, 'time'),
conclusion: exported(row.conclusion, 'string'),
detailsUrl: exported(row.detailsUrl, 'string'),
name: exported(row.name, 'string'),
startedAt: exported(row.startedAt, 'time'),
status: exported(row.status, 'string'),
workflowName: exported(workflow.name, 'string'),
};
}
return {
__typename: exported(row.__typename, 'string'),
context: exported(row.context, 'string'),
startedAt: exported(row.createdAt, 'time'),
state: exported(row.state, 'string'),
targetUrl: exported(row.targetUrl, 'string'),
};
});
}
// Only users and teams are listed; a team prints as `org/slug`.
function requestsOf(node) {
const requests = [];
for (const item of nodesOf(node.reviewRequests)) {
const reviewer = record(record(item).requestedReviewer);
if (reviewer.__typename === 'User') {
requests.push({ __typename: 'User', login: exported(reviewer.login, 'string') });
}
else if (reviewer.__typename === 'Team') {
const org = textValue(record(reviewer.organization).login);
requests.push({
__typename: 'Team',
name: exported(reviewer.name, 'string'),
slug: `${org}/${textValue(reviewer.slug)}`,
});
}
}
return requests;
}
/**
* Every field `gh pr view --json` and `gh pr list --json` accept in gh 2.85:
* the ones issues share, and the ones only a pull request has. `pr view`
* never asks github.com for `projectCards`, which is gone there, so the
* field prints null.
*/
const PULL_FIELD_TABLE = new Map([
...SHARED_FIELDS.map(([name, spec]) => name === 'projectCards' ? [name, { ...spec, view: 'never' }] : [name, spec]),
plain('additions', 'int'),
plain('autoMergeRequest', pointer(['authorEmail', 'raw'], ['commitBody', 'raw'], ['commitHeadline', 'raw'], ['mergeMethod', 'string'], ['enabledAt', 'time'], ['enabledBy', 'author']), 'autoMergeRequest {authorEmail,commitBody,commitHeadline,mergeMethod,enabledAt,' +
'enabledBy{login,...on User{id,name}}}'),
plain('baseRefName', 'string'),
plain('baseRefOid', 'string'),
plain('changedFiles', 'int'),
references('closingIssuesReferences'),
[
'commits',
{
select: 'commits(first: 100) {nodes {commit {authors(first:100) {nodes {name,email,' +
'user{id,login}}},messageHeadline,messageBody,oid,committedDate,authoredDate}}}',
export: commitsOf,
},
],
plain('deletions', 'int'),
nodes('files', 'files(first: 100) {nodes {additions,deletions,path}}', FILE),
plain('fullDatabaseId', 'string'),
plain('headRefName', 'string'),
plain('headRefOid', 'string'),
plain('headRepository', pointer(['id', 'string'], ['name', 'string'], ['nameWithOwner', 'string']), 'headRepository{id,name}'),
plain('headRepositoryOwner', 'owner', 'headRepositoryOwner{id,login,...on User{name}}'),
plain('isCrossRepository', 'bool'),
plain('isDraft', 'bool'),
nodes('latestReviews', 'latestReviews(first: 100) {nodes {author{login},authorAssociation,submittedAt,body,state}}', REVIEW),
plain('maintainerCanModify', 'bool'),
plain('mergeCommit', OID, 'mergeCommit{oid}'),
plain('mergeStateStatus', 'string'),
plain('mergeable', 'string'),
plain('mergedAt', 'raw'),
plain('mergedBy', orNull('author'), 'mergedBy{login,...on User{id,name}}'),
plain('potentialMergeCommit', OID, 'potentialMergeCommit{oid}'),
plain('reviewDecision', 'string'),
[
'reviewRequests',
{
select: 'reviewRequests(first: 100) {nodes {requestedReviewer {__typename,...on User{login},' +
'...on Team{organization{login}name,slug}}}}',
export: requestsOf,
},
],
nodes('reviews', reviews(''), REVIEW, paged('reviews', reviews)),
[
'statusCheckRollup',
{ select: checks(''), pages: { select: checks, at: contexts }, export: checksOf },
],
]);
export const PR_FIELDS = [...PULL_FIELD_TABLE.keys()];
// The `--state` spellings as the pull request states gh lists for each.
const STATES = {
open: ['OPEN'],
closed: ['CLOSED', 'MERGED'],
merged: ['MERGED'],
all: ['OPEN', 'CLOSED', 'MERGED'],
};
/**
* One pull request as `gh pr view --json` reads it: the fields asked for in
* one query, plus the `id` and `number` gh adds for its own follow-ups, every
* connection it pages read to its end, and project items in a query of their
* own. A line that asks for `number` alone is answered from the line itself,
* which is gh's own shortcut.
*/
async function viewedPull(transport, ref, number, fields) {
if (fields.every((field) => field === 'number'))
return { number };
const names = [...fields, 'id', 'number'];
const node = await pullRequestFields(transport, ref, number, selection(PULL_FIELD_TABLE, names, true));
return readRest(PULL_FIELD_TABLE, node, fields, (select, cursor) => pullRequestFields(transport, ref, number, select, cursor));
}
const CHECK_FIELDS = [
'bucket',
'completedAt',
'description',
'event',
'link',
'name',
'startedAt',
'state',
'workflow',
];
const BUCKETS = {
success: 'pass',
neutral: 'skipping',
skipped: 'skipping',
action_required: 'fail',
error: 'fail',
failure: 'fail',
timed_out: 'fail',
cancelled: 'cancel',
};
function pull(value) {
const row = camel(value);
const result = row !== null && typeof row === 'object' ? row : {};
const base = result.base;
const head = result.head;
if (base !== undefined)
result.baseRefName = base.ref;
if (head !== undefined) {
result.headRefName = head.ref;
result.headRefOid = head.sha;
}
delete result.base;
delete result.head;
if ('draft' in result) {
result.isDraft = result.draft;
delete result.draft;
}
result.closed = textValue(result.state).toLowerCase() === 'closed';
return result;
}
function listText(rows) {
return rows
.map((row) => `${textValue(row.number)}\t${textValue(row.state).toUpperCase()}\t${textValue(row.title)}\t${textValue(row.headRefName)}\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)}\nbase:\t${textValue(row.baseRefName)}\nhead:\t${textValue(row.headRefName)}\n--\n${textValue(row.body)}\n`;
}
function target(accessor, texts, fl) {
return repoNumber(accessor, fl, texts[0], 'pull request', 'pull');
}
/**
* `gh pr list`. With `--json` it asks GraphQL for exactly the fields named,
* the way gh's PullRequestList 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 wanted = fl.asStr('state') ?? 'open';
const base = fl.asNonEmpty('base');
const head = fl.asNonEmpty('head');
const fields = jsonFields(fl, PR_FIELDS);
if (fields !== null) {
const rows = await listPullRequestFields(accessor.transport, repoFor(accessor, fl), { states: STATES[wanted] ?? ['OPEN'], base, head }, listLimit(fl, 30, PR_INVALID_LIMIT), selection(PULL_FIELD_TABLE, fields, false));
return typedOut(rows.map((node) => exportedNode(PULL_FIELD_TABLE, node, fields)), fl, '', PR_FIELDS, ctx);
}
const params = { state: wanted === 'merged' ? 'closed' : wanted };
if (base !== undefined)
params.base = base;
if (head !== undefined)
params.head = head;
const values = await listPulls(accessor.transport, repoFor(accessor, fl), params, listLimit(fl, 30, PR_INVALID_LIMIT), wanted === 'merged'
? (row) => row.merged_at !== null && row.merged_at !== undefined
: undefined);
const rows = values.map(pull);
return typedOut(rows, fl, listText(rows), PR_FIELDS, ctx);
}
/**
* `gh pr view`. With `--json` it reads the fields named over GraphQL, as gh
* does (see viewedPull); 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, PR_FIELDS);
if (fields !== null) {
const node = await viewedPull(accessor.transport, ref, number, fields);
return typedOut(exportedNode(PULL_FIELD_TABLE, node, fields), fl, '', PR_FIELDS, ctx);
}
const row = pull(await getPull(accessor.transport, ref, number));
const comments = await commentsFor(accessor, fl, ref, number);
return typedOut(row, fl, ghBool(fl, 'comments') ? commentsText(comments ?? []) : viewText(row), PR_FIELDS, ctx);
}
export async function createCmd(accessor, _paths, _texts, ctx) {
const fl = new FlagView(ctx.flags);
const title = fl.asStr('title');
const head = fl.asStr('head');
const base = fl.asStr('base');
const missing = [
['title', title],
['head', head],
['base', base],
].find(([, value]) => value === undefined || value === '');
if (missing !== undefined)
throw new Error(`--${String(missing[0])} is required in noninteractive mode`);
const body = {
title: title ?? '',
head: head ?? '',
base: base ?? '',
body: (await bodyValue(ctx, fl, { required: true })) ?? '',
draft: ghBool(fl, 'draft'),
maintainer_can_modify: !ghBool(fl, 'no_maintainer_edit'),
};
const created = pull(await createPull(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 body = {};
const title = fl.asStr('title');
const base = fl.asStr('base');
const text = await bodyValue(ctx, fl);
if (title !== undefined)
body.title = title;
if (base !== undefined)
body.base = base;
if (text !== undefined)
body.body = text;
if (Object.keys(body).length === 0)
throw new Error('no pull request fields to edit');
const [ref, number] = target(accessor, texts, fl);
const edited = pull(await editPull(accessor.transport, ref, number, body));
return textOut(`${textValue(edited.url)}\n`);
}
export async function mergeCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const methods = ['merge', 'rebase', 'squash'].filter((name) => ghBool(fl, name));
if (methods.length > 1)
throw new Error('choose only one merge strategy');
const body = { merge_method: methods[0] ?? 'merge' };
const subject = fl.asStr('subject');
const message = await bodyValue(ctx, fl);
const sha = fl.asStr('match_head_commit');
if (subject !== undefined)
body.commit_title = subject;
if (message !== undefined)
body.commit_message = message;
if (sha !== undefined)
body.sha = sha;
const [ref, number] = target(accessor, texts, fl);
await mergePull(accessor.transport, ref, number, body);
return textOut(`✓ Merged pull request ${ref.owner}/${ref.repo}#${String(number)}\n`);
}
export async function closeCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const [ref, number] = target(accessor, texts, fl);
const edited = pull(await editPull(accessor.transport, ref, number, { state: 'closed' }));
return textOut(`✓ Closed pull request ${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 = pull(await getPull(transport, ref, number));
const name = `${ref.owner}/${ref.repo}#${String(number)} (${textValue(row.title)})`;
if (row.merged === true) {
return [
null,
new IOResult({
exitCode: 1,
stderr: new TextEncoder().encode(`X Pull request ${name} can't be reopened because it was already merged\n`),
}),
];
}
if (row.closed !== true) {
return [
new Uint8Array(0),
new IOResult({
stderr: new TextEncoder().encode(`! Pull request ${name} is already open\n`),
}),
];
}
const comment = fl.asStr('comment');
if (comment !== undefined && comment !== '')
await commentPull(transport, ref, number, comment);
await editPull(transport, ref, number, { state: 'open' });
return textOut(`✓ Reopened pull request ${name}\n`);
}
export async function commentCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const [ref, number] = target(accessor, texts, fl);
const comment = pull(await commentPull(accessor.transport, ref, number, (await bodyValue(ctx, fl, { required: true })) ?? ''));
return textOut(`${textValue(comment.url)}\n`);
}
export async function diffCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const [ref, number] = target(accessor, texts, fl);
const value = await diffPull(accessor.transport, ref, number);
if (ghBool(fl, 'name_only')) {
return textOut(changedNames(value)
.map((name) => `${name}\n`)
.join(''));
}
return textOut(value.endsWith('\n') ? value : `${value}\n`);
}
/**
* The files a diff changes, as `gh pr diff --name-only` reads them: the `b/`
* side of each `diff --git` header, quotes and all.
*/
function changedNames(diff) {
return [...diff.matchAll(DIFF_HEADER)].map((m) => `${m[1] ?? ''}${m[2] ?? ''}`.trim());
}
function check(value) {
const row = camel(value);
row.link = row.detailsUrl ?? '';
delete row.detailsUrl;
const output = row.output;
row.description = output?.summary ?? '';
const state = textValue(row.conclusion ?? row.status);
row.state = state;
row.bucket = BUCKETS[state] ?? 'pending';
const app = row.app;
row.workflow = app?.name ?? '';
return row;
}
export async function checksCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const [ref, number] = target(accessor, texts, fl);
const rows = (await pullChecks(accessor.transport, ref, number)).map(check);
const human = rows
.map((row) => `${textValue(row.name)}\t${textValue(row.state)}\t${textValue(row.link)}\n`)
.join('');
const out = await typedOut(rows, fl, human, CHECK_FIELDS, ctx);
if (out !== null) {
const buckets = new Set(rows.map((row) => row.bucket));
if (buckets.has('fail'))
out[1].exitCode = 1;
else if (buckets.has('pending'))
out[1].exitCode = 8;
}
return out;
}
//# sourceMappingURL=pull.js.map