ci: finalize pac status and gh heredoc guard
This commit is contained in:
@@ -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)]));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
]]),
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user