UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

431 lines • 19.5 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. ========= 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