ci: finalize pac status and gh heredoc guard

This commit is contained in:
Codex
2026-07-05 12:46:49 +00:00
parent 3e2b28e26d
commit 79e9288d5f
16 changed files with 364 additions and 33 deletions
+181
View File
@@ -10,6 +10,7 @@ import { spawnSync } from "node:child_process";
import { rootPath, type UniDeskConfig } from "../config";
import type { RenderedCliResult } from "../output";
import { applyLocalCaddyManagedSite } from "../pk01-caddy";
import { runPlatformInfraPipelinesAsCodeCommand } from "../platform-infra-pipelines-as-code";
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
import { runRemoteSshCommandCapture } from "../remote";
import { startJob } from "../jobs";
@@ -325,8 +326,188 @@ export async function status(config: UniDeskConfig, options: StatusOptions): Pro
return await statusYamlLane(config, options, resolveAgentRunLaneTarget(options));
}
function isPipelinesAsCodeMigratedLane(spec: AgentRunLaneSpec): boolean {
return spec.gitMirror.readUrl.includes("gitea-http.")
|| spec.gitops.repoURL.includes("gitea-http.")
|| spec.gitMirror.readUrl.includes("/mirrors/");
}
function pacStatusCommand(spec: AgentRunLaneSpec, full = false): string {
return `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${spec.nodeId}${full ? " --full" : ""}`;
}
function giteaMirrorStatusCommand(spec: AgentRunLaneSpec): string {
return `bun scripts/cli.ts platform-infra gitea mirror status --target ${spec.nodeId}`;
}
export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown>> {
const spec = target.spec;
const pacStatus = record(await runPlatformInfraPipelinesAsCodeCommand(config, ["status", "--target", spec.nodeId, "--full"]));
const pacSummary = record(pacStatus.summary);
const latestPipelineRun = record(pacSummary.latestPipelineRun);
const artifact = record(pacSummary.artifact);
const pacArgo = record(pacSummary.argo);
const pipelineRunName = options.pipelineRun ?? stringOrNull(latestPipelineRun.name);
const runtimeProbe = await timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)]));
const runtimePayload = captureJsonPayload(runtimeProbe.value);
const manager = record(runtimePayload.manager);
const database = record(runtimePayload.database);
const secrets = record(runtimePayload.secrets);
const localPostgres = record(runtimePayload.localPostgres);
const sourceCommit = options.sourceCommit ?? stringOrNull(artifact.sourceCommit) ?? stringOrNull(latestPipelineRun.sourceCommit);
const expectedGitopsRevision = stringOrNull(artifact.gitopsCommit);
const argoRevision = stringOrNull(pacArgo.revision);
const argoSyncedToGitops = Boolean(expectedGitopsRevision && argoRevision === expectedGitopsRevision);
const managerSourceMatchesExpected = Boolean(sourceCommit && manager.sourceCommit === sourceCommit);
const pipelineSucceeded = latestPipelineRun.status === "True";
const pacReady = pacStatus.ok === true && pacSummary.ready === true;
const blockers = [
...(pacReady ? [] : ["pac-not-ready"]),
...(pipelineRunName !== null ? [] : ["pac-pipelinerun-missing"]),
...(pipelineRunName === null || pipelineSucceeded ? [] : ["pac-pipelinerun-not-succeeded"]),
...(expectedGitopsRevision !== null ? [] : ["pac-gitops-revision-unresolved"]),
...(expectedGitopsRevision === null || argoSyncedToGitops ? [] : ["argo-revision-stale"]),
...(runtimePayload.runtimeNamespaceExists === true ? [] : ["runtime-namespace-missing"]),
...(manager.deploymentExists === true ? [] : ["manager-deployment-missing"]),
...(manager.deploymentExists !== true || sourceCommit === null || managerSourceMatchesExpected ? [] : ["manager-source-stale"]),
...(manager.serviceExists === true ? [] : ["manager-service-missing"]),
...(spec.database.mode === "external-postgres" && database.secretPresent !== true ? ["database-secret-missing"] : []),
...(secrets.ready === true ? [] : ["lane-secret-missing"]),
...(spec.database.localPostgresExpectedAbsent && localPostgres.absent !== true ? ["local-postgres-present"] : []),
];
const aligned = blockers.length === 0 && pipelineSucceeded && argoSyncedToGitops && managerSourceMatchesExpected;
const runtimeAlignment = {
pipelineSucceeded,
argoRevision,
argoSyncedToGitops,
managerSourceCommit: stringOrNull(manager.sourceCommit),
managerSourceMatchesExpected,
runtimeAligned: blockers.every((blocker) => blocker.startsWith("pac-") || blocker === "argo-revision-stale"),
};
const nextAction = aligned
? { code: "none", summary: "PaC/Gitea migrated lane aligned; no action required", command: null }
: blockers.includes("argo-revision-stale")
? { code: "argo-refresh", summary: "GitOps commit is published but Argo has not observed it yet", command: `bun scripts/cli.ts agentrun control-plane refresh --node ${spec.nodeId} --lane ${spec.lane} --confirm` }
: { code: "inspect-pac-status", summary: `PaC/Gitea closeout blocked: ${blockers.join(", ")}`, command: pacStatusCommand(spec, true) };
const migration = {
migrated: true,
sourceAuthority: "gitea-pipelines-as-code",
triggerPath: "Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime",
legacyGitMirrorDisposition: "migration-readonly",
legacyTriggerCurrentDisabled: true,
valuesPrinted: false,
};
const summary = {
aligned,
runtimeAligned: runtimeAlignment.runtimeAligned,
blockers,
warnings: [],
sourceCommit,
expectedPipelineRun: pipelineRunName,
expectedGitopsRevision,
runtimeAlignment,
migration,
pac: {
ready: pacSummary.ready ?? false,
webhookCount: pacSummary.webhookCount ?? null,
latestPipelineRun: {
name: pipelineRunName,
status: latestPipelineRun.status ?? null,
reason: latestPipelineRun.reason ?? null,
durationSeconds: latestPipelineRun.durationSeconds ?? null,
sourceCommit: stringOrNull(latestPipelineRun.sourceCommit),
},
taskRuns: pacSummary.taskRuns ?? [],
artifact: {
imageStatus: artifact.imageStatus ?? null,
envIdentity: artifact.envIdentity ?? null,
digest: artifact.digest ?? null,
gitopsCommit: expectedGitopsRevision,
sourceCommit,
valuesPrinted: false,
},
},
argo: {
syncStatus: pacArgo.sync ?? null,
healthStatus: pacArgo.health ?? null,
revision: argoRevision,
syncedToGitops: argoSyncedToGitops,
},
runtime: {
namespaceExists: runtimePayload.runtimeNamespaceExists ?? false,
manager: {
deploymentExists: manager.deploymentExists ?? false,
serviceExists: manager.serviceExists ?? false,
sourceCommit: stringOrNull(manager.sourceCommit),
sourceMatchesExpected: managerSourceMatchesExpected,
envIdentity: manager.envIdentity ?? null,
},
databaseSecretPresent: database.secretPresent ?? null,
secretsReady: secrets.ready ?? null,
localPostgresAbsent: localPostgres.absent ?? null,
},
nextAction,
};
const result: Record<string, unknown> = {
ok: runtimeProbe.value.exitCode === 0 && pacStatus.ok === true && blockers.length === 0,
command: "agentrun control-plane status",
mode: "pipelines-as-code-migrated-lane",
configPath: target.configPath,
target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec),
summary,
alignment: {
aligned,
blockers,
warnings: [],
sourceCommit,
expectedPipelineRun: pipelineRunName,
expectedGitopsRevision,
runtimeAlignment,
migration,
},
timings: {
runtimeMs: runtimeProbe.elapsedMs,
totalMs: runtimeProbe.elapsedMs,
},
disclosure: {
output: options.full || options.raw ? "full" : "compact-summary",
full: options.full,
raw: options.raw,
legacyGitMirror: "migration-readonly",
fullCommand: agentRunControlPlaneStatusCommand(spec, options, true),
pacStatusCommand: pacStatusCommand(spec, true),
},
next: {
action: nextAction,
pacStatus: pacStatusCommand(spec),
giteaMirrorStatus: giteaMirrorStatusCommand(spec),
refresh: `bun scripts/cli.ts agentrun control-plane refresh --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
triggerCurrent: null,
},
valuesPrinted: false,
};
if (options.full || options.raw || runtimeProbe.value.exitCode !== 0) {
result.captures = {
runtime: compactCapture(runtimeProbe.value, { full: options.full || options.raw || runtimeProbe.value.exitCode !== 0 }),
};
}
if (options.full || options.raw) {
result.pipelinesAsCode = pacStatus;
result.runtime = runtimePayload;
result.legacy = {
gitMirror: {
disposition: "migration-readonly",
readUrl: spec.gitMirror.readUrl,
note: "Legacy git-mirror status is not used as a closeout blocker for PaC/Gitea migrated lanes.",
},
};
}
return result;
}
export async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown>> {
const spec = target.spec;
if (isPipelinesAsCodeMigratedLane(spec)) return await statusPipelinesAsCodeLane(config, options, target);
const sourceProbe = await timedStatusStage("source", () => spec.source.statusMode === "k3s-git-mirror"
? capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)])
: capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)]));
+54
View File
@@ -69,6 +69,60 @@ export function renderAgentRunControlPlaneStatusSummary(result: Record<string, u
const timings = record(result.timings);
const blockers = Array.isArray(summary.blockers) ? summary.blockers.map(String) : [];
const warnings = Array.isArray(summary.warnings) ? summary.warnings.map(String) : [];
const migration = record(summary.migration);
if (migration.migrated === true && migration.sourceAuthority === "gitea-pipelines-as-code") {
const pac = record(summary.pac);
const latestPipelineRun = record(pac.latestPipelineRun);
const artifact = record(pac.artifact);
const taskRuns = Array.isArray(pac.taskRuns) ? pac.taskRuns.map(record) : [];
const taskRows = taskRuns.slice(0, 8).map((taskRun) => [
displayValue(taskRun.name),
displayValue(taskRun.status),
displayValue(taskRun.reason),
displayValue(taskRun.durationSeconds),
]);
const lines = [
"AGENTRUN CONTROL-PLANE STATUS",
renderTable(
["NODE", "LANE", "MODE", "SOURCE", "PIPELINE", "ALIGNED", "RUNTIME", "BLOCKERS"],
[[
displayValue(node.id ?? target.node ?? "-"),
displayValue(target.lane ?? "-"),
"PaC/Gitea",
shortSha(summary.sourceCommit),
displayValue(summary.expectedPipelineRun ?? "-"),
yesNo(summary.aligned),
yesNo(summary.runtimeAligned),
blockers.length === 0 ? "-" : blockers.join(","),
]],
),
"",
renderTable(
["COMPONENT", "STATUS", "DETAIL"],
[
["trigger", yesNo(pac.ready), `webhooks=${displayValue(pac.webhookCount)} path=${displayValue(migration.triggerPath)}`],
["pipelinerun", yesNo(latestPipelineRun.status === "True"), `run=${displayValue(latestPipelineRun.name)} status=${displayValue(latestPipelineRun.status)} reason=${displayValue(latestPipelineRun.reason)} duration=${displayValue(latestPipelineRun.durationSeconds)}s`],
["image", yesNo(artifact.imageStatus === "reused" || artifact.imageStatus === "built"), `status=${displayValue(artifact.imageStatus)} env=${displayValue(artifact.envIdentity)} digest=${shortSha(artifact.digest)} gitops=${shortSha(artifact.gitopsCommit)}`],
["argo", yesNo(argo.syncedToGitops), `sync=${displayValue(argo.syncStatus)} health=${displayValue(argo.healthStatus)} revision=${shortSha(argo.revision)}`],
["runtime", yesNo(runtimeManager.sourceMatchesExpected), `manager=${shortSha(runtimeManager.sourceCommit)} secrets=${yesNo(runtime.secretsReady)} dbSecret=${yesNo(runtime.databaseSecretPresent)} localPgAbsent=${yesNo(runtime.localPostgresAbsent)}`],
],
),
"",
"TASKRUN DURATIONS",
taskRows.length === 0 ? "-" : renderTable(["TASKRUN", "STATUS", "REASON", "DURATION_S"], taskRows),
"",
warnings.length === 0 ? "WARNINGS\n-" : ["WARNINGS", ...warnings.map((warning) => `- ${warning}`)].join("\n"),
"",
"NEXT",
` action: ${displayValue(nextAction.summary ?? "-")}`,
nextAction.command ? ` run: ${displayValue(nextAction.command)}` : null,
` pac: ${displayValue(next.pacStatus ?? disclosure.pacStatusCommand ?? "-")}`,
` full: ${displayValue(next.statusFull ?? disclosure.fullCommand ?? "bun scripts/cli.ts agentrun control-plane status --full")}`,
"",
`TIMINGS runtime=${displayValue(timings.runtimeMs)}ms total=${displayValue(timings.totalMs)}ms`,
].filter((line): line is string => line !== null);
return renderedCliResult(result.ok !== false, "agentrun control-plane status", `${lines.join("\n")}\n`);
}
const sourceSnapshotMode = source.sourceAuthority === "git-mirror-snapshot" || source.snapshotPresent !== null;
const sourceStatus = sourceSnapshotMode ? yesNo(source.snapshotPresent) : yesNo(source.workspaceClean);
const sourceDetail = sourceSnapshotMode
+7 -4
View File
@@ -9,16 +9,19 @@ import type { GitHubOptions, GitHubTokenProbe } from "./types";
export function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
if (options.comment === undefined && options.commentFile === undefined) return null;
if (options.comment !== undefined && options.commentFile !== undefined) {
throw new Error(`${command} --comment and --comment-file/--comment-stdin are mutually exclusive`);
if (options.comment !== undefined) {
throw new Error(`${command} inline --comment is unsupported; use --comment-stdin with a quoted heredoc`);
}
if (options.body !== undefined || options.bodyFile !== undefined) {
throw new Error(`${command} --comment/--comment-file/--comment-stdin cannot be combined with --body/--body-file/--body-stdin`);
throw new Error(`${command} --comment-stdin cannot be combined with --body/--body-file/--body-stdin`);
}
if (options.commentFile !== undefined) {
if (options.commentFile !== "-") {
throw new Error(`${command} --comment-file is unsupported for lifecycle comments; use --comment-stdin with a quoted heredoc`);
}
return readIssueCommentBody({ ...options, body: undefined, bodyFile: options.commentFile });
}
return readIssueCommentBody({ ...options, body: options.comment, bodyFile: undefined });
return null;
}
export function tokenFromEnvironment(): GitHubTokenProbe {
+2 -1
View File
@@ -537,13 +537,14 @@ function renderScanEscape(result: GitHubCommandResult): string {
function renderIssueLifecycle(result: GitHubCommandResult): string {
const issue = record(result.issue);
const comment = record(result.comment);
const commentStatus = comment.id ?? (comment.planned === true ? "planned" : "-");
return [
`gh ${result.command} (${result.dryRun === true ? "dry-run" : "updated"})`,
"",
ghTable(["ISSUE", "STATE", "COMMENT", "UPDATED", "TITLE"], [[
`#${ghText(result.issueNumber ?? issue.number)}`,
ghText(issue.state ?? record(result.wouldPatch).state),
ghText(comment.id ?? "-"),
ghText(commentStatus),
shortDate(issue.updatedAt),
ghShort(ghText(issue.title), 96),
]]),
+4 -4
View File
@@ -29,7 +29,7 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue comment patch <commentId> --body-patch-stdin [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue comment edit <commentId> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for issue comment update]",
"bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--number N compat] [--comment <short-text>|--comment-stdin|--comment-file <file|->] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--number N compat] [--comment-stdin] [--dry-run]",
"bun scripts/cli.ts gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]",
"bun scripts/cli.ts gh issue delete <number> [unsupported: use close]",
"bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]",
@@ -86,7 +86,7 @@ export function ghHelp(): unknown {
"issue patch reads the current GitHub issue body, applies a Codex apply_patch envelope against virtual file issue.md from --body-patch-stdin or --body-patch-file <file|->, then runs the same issue body guard before PATCH. It returns old/new bodySha, updatedAt, patch summary, and bounded previews; context mismatch fails with redacted diagnostics and no GitHub write.",
"issue comment view/read reads one comment by commentId. Default output is compact metadata plus bodyChars/bodySha/preview; --full includes that one full comment body. issue comment create/update/edit accept --body-stdin or --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally. Use comment update/edit to correct existing wording in place; delete remains for intentional removal.",
"issue comment patch reads the current issue comment by commentId, applies a Codex apply_patch envelope against virtual file comment.md, then PATCHes only that comment. It returns comment id, old/new bodySha, updatedAt, patch summary, and redacted mismatch diagnostics without echoing the full comment body.",
"issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text>, --comment-stdin, or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-stdin is the first-class heredoc path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue view <number> --json body or --full/--raw on view when full text is needed.",
"issue close/reopen default success output is compact and omits full issue.body. Optional --comment-stdin posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-stdin with a quoted heredoc is the only lifecycle closeout comment path; inline --comment and --comment-file are unsupported. Use gh issue view <number> --json body or --full/--raw on view when full text is needed.",
"issue stale-close is the reusable lifecycle cleanup path for policies such as closing open issues inactive for more than 48 hours. It selects open issues by GitHub updatedAt older than observedAt - --inactive-hours, treats comments and state changes as activity, filters pull requests, supports --dry-run, and returns bounded candidate/closed/failure summaries without echoing full bodies.",
"For one-shot issue writes, prefer quoted heredoc stdin: bun scripts/cli.ts gh issue update <number> --repo owner/name --body-stdin <<'EOF' ... EOF or gh issue comment create <number> --body-stdin <<'EOF' ... EOF. --body-file <file|-> remains available for reusable files and pipes.",
"For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue/PR Markdown writes use --body-stdin for heredoc/stdin or --body-file <file|-> for reusable files.",
@@ -164,8 +164,8 @@ export function ghScopedHelpNotes(tokens: string[]): string[] {
notes.push("Use `issue comments <number>` as the low-friction recent-progress path; default output is bounded and structured output is at `.data.comments`.");
notes.push("`--full` or `--raw` includes full bodies for the bounded recent list without changing the stable top-level `comments` path.");
} else if (key === "issue close" || key === "issue reopen") {
notes.push("Issue close/reopen can post a lifecycle comment with `--comment`, `--comment-stdin`, or `--comment-file <file|->` before changing state.");
notes.push("For long closeout evidence, prefer `--comment-stdin` with a quoted heredoc.");
notes.push("Issue close/reopen can post a lifecycle comment with `--comment-stdin` before changing state.");
notes.push("Use `--comment-stdin` with a quoted heredoc for closeout evidence; inline `--comment` and `--comment-file` are unsupported.");
} else if (key === "pr comment" || key.startsWith("pr comment ")) {
notes.push("PR comments are GitHub issue comments under the hood; use comment id targets for update/edit/delete.");
notes.push("Use `pr comment view <commentId> --full` to read one full comment body by id.");
+16 -2
View File
@@ -194,9 +194,23 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--inactive-hours is only supported by gh issue stale-close");
}
if (optionWasProvided(args, "--comment") && !(top === "issue" && (sub === "close" || sub === "reopen"))) {
if (optionWasProvided(args, "--comment")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--comment/--comment-file is only supported by gh issue close/reopen; use gh issue comment create for standalone comments");
return validationError(command, options.repo, "inline --comment is unsupported because shell quoting corrupts Markdown; use --comment-stdin with a quoted heredoc", {
supportedCommands: [
"bun scripts/cli.ts gh issue close <number> --repo owner/name --comment-stdin <<'EOF'\n<comment markdown>\nEOF",
"bun scripts/cli.ts gh issue reopen <number> --repo owner/name --comment-stdin <<'EOF'\n<comment markdown>\nEOF",
],
});
}
if (optionWasProvided(args, "--comment-file")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--comment-file is unsupported for issue lifecycle comments; use --comment-stdin with a quoted heredoc", {
supportedCommands: [
"bun scripts/cli.ts gh issue close <number> --repo owner/name --comment-stdin <<'EOF'\n<comment markdown>\nEOF",
"bun scripts/cli.ts gh issue reopen <number> --repo owner/name --comment-stdin <<'EOF'\n<comment markdown>\nEOF",
],
});
}
if ((optionWasProvided(args, "--description") || optionWasProvided(args, "--private") || optionWasProvided(args, "--public") || optionWasProvided(args, "--auto-init")) && !(top === "repo" && sub === "create")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
+50 -10
View File
@@ -10,6 +10,8 @@ import { ghShort, ghTable, ghText } from "./render";
import { GITHUB_REST_PAGE_SIZE, ISSUE_LIST_JSON_FIELDS, MAX_ISSUE_LIST_LIMIT } from "./types";
import type { GitHubCommandResult, IssueListJsonField, IssueListState } from "./types";
const COMPACT_JSON_ISSUE_LIMIT = 40;
export function shellWord(value: string): string {
return JSON.stringify(value);
}
@@ -82,6 +84,21 @@ export async function issueList(repo: string, token: string, state: IssueListSta
? listedIssues.filter((issue) => issue.title.startsWith(normalizedTitlePrefix))
: listedIssues;
const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice();
const selectedIssues = issues.map((issue) => issueListSummary(issue, fields));
const compactJson = jsonFields !== undefined && !noDump;
const returnedIssues = compactJson ? selectedIssues.slice(0, COMPACT_JSON_ISSUE_LIMIT) : selectedIssues;
const omittedIssues = Math.max(0, selectedIssues.length - returnedIssues.length);
const fullCommand = [
"bun scripts/cli.ts gh issue list",
"--repo", shellWord(repo),
"--state", state,
"--limit", String(limit),
...(normalizedSearch.length > 0 ? ["--search", shellWord(normalizedSearch)] : []),
...(normalizedTitlePrefix.length > 0 ? ["--title-prefix", shellWord(normalizedTitlePrefix)] : []),
...labels.flatMap((label) => ["--label", shellWord(label)]),
"--json", shellWord(fields.join(",")),
"--full",
].join(" ");
const payload: GitHubCommandResult = {
ok: true,
command: "issue list",
@@ -109,10 +126,29 @@ export async function issueList(repo: string, token: string, state: IssueListSta
rawCount: result.rawCount,
searchTotalCount: result.searchTotalCount,
searchIncomplete: result.searchIncomplete,
pagination: issueListPaginationSummary(result),
...(compactJson ? {} : { pagination: issueListPaginationSummary(result) }),
hasMore: result.hasMore,
jsonFields: fields,
issues: issues.map((issue) => issueListSummary(issue, fields)),
issues: returnedIssues,
...(compactJson
? {
issueListDisclosure: {
mode: "bounded-json",
returned: returnedIssues.length,
omitted: omittedIssues,
totalMatchedInBoundedScan: issues.length,
inlineLimit: COMPACT_JSON_ISSUE_LIMIT,
fullCommand,
note: "Default --json issue list output is bounded for interactive use; add --full for complete selected fields and pagination/request metadata.",
},
}
: {}),
...(omittedIssues > 0
? {
omittedIssues,
fullCommand,
}
: {}),
...(result.hasMore && limit < MAX_ISSUE_LIST_LIMIT
? {
next: {
@@ -129,14 +165,18 @@ export async function issueList(repo: string, token: string, state: IssueListSta
},
}
: {}),
request: {
method: "GET",
path: normalizedSearch ? "/search/issues" : "/repos/{owner}/{repo}/issues",
query: normalizedSearch
? { q: `${normalizedSearch} repo:${repo} type:issue${state === "all" ? "" : ` state:${state}`}${labels.map((label) => ` ${githubSearchLabelQualifier(label)}`).join("")}`, per_page: GITHUB_REST_PAGE_SIZE }
: { state, labels, per_page: GITHUB_REST_PAGE_SIZE },
localTitlePrefixFilter: normalizedTitlePrefix || null,
},
...(compactJson
? {}
: {
request: {
method: "GET",
path: normalizedSearch ? "/search/issues" : "/repos/{owner}/{repo}/issues",
query: normalizedSearch
? { q: `${normalizedSearch} repo:${repo} type:issue${state === "all" ? "" : ` state:${state}`}${labels.map((label) => ` ${githubSearchLabelQualifier(label)}`).join("")}`, per_page: GITHUB_REST_PAGE_SIZE }
: { state, labels, per_page: GITHUB_REST_PAGE_SIZE },
localTitlePrefixFilter: normalizedTitlePrefix || null,
},
}),
note: "GitHub's issues endpoint may include pull requests; this command filters pull requests from .issues.",
};
return jsonFields === undefined && !noDump ? withIssueListRendered(payload) : payload;