UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

232 lines • 11.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. ========= 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