fix: smooth hwlab pr and rollout visibility

This commit is contained in:
Codex
2026-05-31 08:35:12 +00:00
parent 995b75dd2a
commit 32b3bd7ce4
3 changed files with 93 additions and 62 deletions
+36 -9
View File
@@ -814,6 +814,32 @@ function parsePositionalNumberForCommand(repo: string, args: string[], startInde
return parseNumberForCommand(repo, targets[0], label);
}
function resolvePositionalPrReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult {
const targets = positionalArgs(args.slice(startIndex));
if (targets.length !== 1) {
return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, {
supportedCommands: [
`bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`,
`bun scripts/cli.ts gh ${label} ${options.repo}#<number>`,
],
});
}
const shorthand = parseOwnerRepoNumberShorthand(targets[0]);
if (shorthand !== null) {
const explicitRepo = optionValue(args, "--repo");
if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) {
return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, {
shorthand,
explicitRepo,
});
}
return { repo: shorthand.repo, number: shorthand.number, shorthand };
}
const number = parseNumberForCommand(options.repo, targets[0], label);
if (typeof number !== "number") return number;
return { repo: options.repo, number };
}
function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthandReference | null {
if (raw === undefined) return null;
const match = /^([^/#\s]+)\/([^/#\s]+)#([1-9]\d*)$/u.exec(raw);
@@ -5911,14 +5937,14 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue board-row upsert <issueNumber> [--repo owner/name] --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] --board-issue 20 [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh preflight <prNumber> [--repo owner/name] [--full|--raw] [compatibility alias for gh pr preflight]",
"bun scripts/cli.ts gh preflight <prNumber|owner/repo#number> [--repo owner/name] [--full|--raw] [compatibility alias for gh pr preflight]",
"bun scripts/cli.ts gh pr list [owner/repo] [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
"bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--limit N] [number may appear before or after options]",
"bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]",
"bun scripts/cli.ts gh pr read <number|owner/repo#number> [--repo owner/name] [--number N] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]",
"bun scripts/cli.ts gh pr view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for pr read]",
"bun scripts/cli.ts gh pr preflight <number> [--repo owner/name] [--full|--raw] [number may appear before or after options]",
"bun scripts/cli.ts gh pr closeout <number> [--repo owner/name] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]",
"bun scripts/cli.ts gh pr preflight <number|owner/repo#number> [--repo owner/name] [--full|--raw] [number may appear before or after options]",
"bun scripts/cli.ts gh pr closeout <number|owner/repo#number> [--repo owner/name] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]",
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
"bun scripts/cli.ts gh pr edit <number> [--title title] [--body-file <file>|--body-file -|--body <text>] [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title title] [--repo owner/name] [--dry-run]",
@@ -5958,6 +5984,7 @@ export function ghHelp(): unknown {
"PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.",
"PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.",
"PR read is the canonical read path; view remains a compatibility alias. PR read/view accept owner/repo#number shorthand and --number N as a compatibility alias for the positional PR number; shorthand derives --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. PR read/view supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
"PR preflight/closeout accept the same owner/repo#number shorthand as PR read/view so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.",
"PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.",
"PR preflight is a low-noise read-only closeout helper. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and the explicit UniDesk REST CLI no-merge policy. Use --full or --raw to include all fetched status contexts.",
"PR merge is a guarded write operation: it first reads closeout metadata, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Use --dry-run to see the exact merge plan without writing.",
@@ -6073,9 +6100,9 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (top === "auth" && sub === "status") return authStatus(options.repo);
if (top === "preflight") {
const number = parsePositionalNumberForCommand(options.repo, args, 1, "preflight");
if (typeof number !== "number") return number;
return prPreflight(options.repo, number, "preflight", options.full || options.raw);
const resolved = resolvePositionalPrReference(args, 1, "preflight", options);
if (isGitHubCommandResult(resolved)) return resolved;
return prPreflight(resolved.repo, resolved.number, "preflight", options.full || options.raw);
}
if (top === "issue") {
@@ -6197,9 +6224,9 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
}
if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics.");
if (sub === "preflight" || sub === "closeout") {
const number = parsePositionalNumberForCommand(options.repo, args, 2, `pr ${sub}`);
if (typeof number !== "number") return number;
return prPreflight(options.repo, number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw);
const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options);
if (isGitHubCommandResult(resolved)) return resolved;
return prPreflight(resolved.repo, resolved.number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw);
}
if (sub === "comment" && third === "delete") {
const commentId = parseNumberForCommand(options.repo, args[3], "pr comment delete");
+55 -51
View File
@@ -414,6 +414,31 @@ function compactCommandResult(result: CommandJsonResult): Record<string, unknown
};
}
function compactControlPlaneRefresh(value: Record<string, unknown>): Record<string, unknown> {
return {
ok: value.ok === true,
phase: value.phase ?? null,
sourceCommit: value.sourceCommit ?? null,
renderDir: value.renderDir ?? null,
applyOk: nested(value, ["apply", "ok"]) ?? null,
cleanupOk: nested(value, ["cleanupObsoleteCronJobs", "ok"]) ?? null,
applyStdout: tailText(nested(value, ["apply", "stdout"]), 1200),
degradedReason: value.degradedReason ?? null,
};
}
function compactTriggerCommandResult(value: Record<string, unknown> | null): Record<string, unknown> | null {
if (value === null) return null;
return {
ok: value.ok ?? null,
exitCode: value.exitCode ?? null,
stdout: tailText(value.stdout, 1200),
stderr: tailText(value.stderr, 1200),
stdoutBytes: value.stdoutBytes ?? null,
stderrBytes: value.stderrBytes ?? null,
};
}
function isCommandSuccess(result: CommandJsonResult): boolean {
if (!result.ok) return false;
const topOk = record(result.parsed).ok;
@@ -477,14 +502,6 @@ function getV02Head(): string | null {
return match?.[0] ?? null;
}
function getV02CachedHead(): string | null {
const result = v02WorkspaceScript("git rev-parse origin/v0.2", 30_000);
if (!isCommandSuccess(result)) return null;
const output = String(nested(result.parsed, ["data", "stdout"]) ?? result.stdout).trim();
const match = /[0-9a-f]{40}/iu.exec(output);
return match?.[0] ?? null;
}
function v02PipelineRunName(sourceCommit: string): string {
return `${V02_PIPELINERUN_PREFIX}-${shortSha(sourceCommit)}`;
}
@@ -539,31 +556,6 @@ function secondsBetween(start: unknown, end: unknown): number | null {
return Math.round((endMs - startMs) / 1000);
}
function pipelineRunStatusRow(item: unknown, nowMs: number): Record<string, unknown> {
const root = record(item);
const metadata = record(root.metadata);
const status = record(root.status);
const labels = record(metadata.labels);
const conditions = Array.isArray(status.conditions) ? status.conditions : [];
const condition = record(conditions[0]);
const startTime = status.startTime ?? null;
const completionTime = status.completionTime ?? null;
const startMs = timestampMs(startTime);
const conditionStatus = typeof condition.status === "string" ? condition.status : null;
return {
name: metadata.name ?? null,
sourceCommit: labels["hwlab.pikastech.local/source-commit"] ?? null,
status: conditionStatus,
reason: condition.reason ?? null,
createdAt: metadata.creationTimestamp ?? null,
startTime,
completionTime,
durationSeconds: secondsBetween(startTime, completionTime),
elapsedSeconds: startMs === null || completionTime !== null ? null : Math.max(0, Math.round((nowMs - startMs) / 1000)),
active: completionTime === null && conditionStatus !== "True" && conditionStatus !== "False",
};
}
function pipelineRunStatusRowFromLine(line: string, nowMs: number): Record<string, unknown> | null {
const [
name = "",
@@ -592,8 +584,7 @@ function pipelineRunStatusRowFromLine(line: string, nowMs: number): Record<strin
};
}
function parsePipelineRunRows(text: string, limit: number): Record<string, unknown> {
const nowMs = Date.now();
function parsePipelineRunRows(text: string, limit: number, nowMs = Date.now()): Record<string, unknown> {
const rows = text
.split(/\r?\n/u)
.map((line) => pipelineRunStatusRowFromLine(line.trim(), nowMs))
@@ -698,9 +689,15 @@ function shellSectionOk(section: ShellSection | undefined): boolean {
return section?.exitCode === 0;
}
function v02ControlPlaneStatusBundle(pipelineRun: string | null): CommandJsonResult {
function v02ControlPlaneStatusBundle(sourceCommit: string | null | undefined): CommandJsonResult {
const sourceCommitLine = sourceCommit === undefined
? `source_commit=$(git -C ${shellQuote(V02_WORKSPACE)} rev-parse origin/v0.2 2>/dev/null || true)`
: `source_commit=${shellQuote(sourceCommit ?? "")}`;
const script = [
"set +e",
sourceCommitLine,
"pipeline_run=",
`if [ -n "$source_commit" ]; then pipeline_run="${V02_PIPELINERUN_PREFIX}-$(printf '%s' "$source_commit" | cut -c1-12)"; fi`,
"section() {",
" name=\"$1\"",
" shift",
@@ -709,18 +706,18 @@ function v02ControlPlaneStatusBundle(pipelineRun: string | null): CommandJsonRes
" code=$?",
" printf '\\n__UNIDESK_SECTION_END__ %s exit=%s\\n' \"$name\" \"$code\"",
"}",
"section sourceCommit printf '%s\\n' \"$source_commit\"",
"section queryNow date -u +%Y-%m-%dT%H:%M:%SZ",
`section controlPlane kubectl get pipeline,role,rolebinding,serviceaccount -n ${shellQuote(CI_NAMESPACE)} -l hwlab.pikastech.local/gitops-target=v02 -o name`,
`section obsoleteCronJobs kubectl get cronjob -n ${shellQuote(CI_NAMESPACE)} ${shellQuote(V02_POLLER)} ${shellQuote(V02_RECONCILER)} --ignore-not-found -o name`,
`section argo kubectl get application -n ${shellQuote(ARGO_NAMESPACE)} ${shellQuote(V02_APP)} -o 'jsonpath={.spec.source.targetRevision}{"\\n"}{.spec.source.path}{"\\n"}{.status.sync.revision}{"\\n"}{.status.sync.status}{"\\n"}{.status.health.status}{"\\n"}'`,
pipelineRun === null
? "section pipelineRun sh -c 'true'"
: `section pipelineRun kubectl get pipelinerun -n ${shellQuote(CI_NAMESPACE)} ${shellQuote(pipelineRun)} -o 'jsonpath={.status.conditions[0].status}{"\\n"}{.status.conditions[0].reason}{"\\n"}{.status.conditions[0].message}{"\\n"}'`,
`if [ -n "$pipeline_run" ]; then section pipelineRun kubectl get pipelinerun -n ${shellQuote(CI_NAMESPACE)} "$pipeline_run" -o 'jsonpath={.status.conditions[0].status}{"\\n"}{.status.conditions[0].reason}{"\\n"}{.status.conditions[0].message}{"\\n"}'; else section pipelineRun sh -c 'true'; fi`,
`section recentPipelineRuns kubectl get pipelinerun -n ${shellQuote(CI_NAMESPACE)} -l hwlab.pikastech.local/gitops-target=v02 -o ${shellQuote(pipelineRunRowsJsonPath())}`,
].join("\n");
return g14K3s(["script", "--", script], 60_000);
}
function listV02PipelineRunsCompactFromText(text: string, commandOk: boolean, command: string[] | string, exitCode: number | null, stderr: string, limit = 8): Record<string, unknown> {
function listV02PipelineRunsCompactFromText(text: string, commandOk: boolean, command: string[] | string, exitCode: number | null, stderr: string, limit = 8, nowMs = Date.now()): Record<string, unknown> {
if (!commandOk) {
return {
ok: false,
@@ -731,7 +728,7 @@ function listV02PipelineRunsCompactFromText(text: string, commandOk: boolean, co
activeItems: [],
};
}
return parsePipelineRunRows(text, limit);
return parsePipelineRunRows(text, limit, nowMs);
}
function pipelinePrefixesForLane(lane: "v02" | "g14" | "all"): string[] {
@@ -1177,10 +1174,12 @@ function deleteV02PipelineRun(pipelineRun: string): CommandJsonResult {
return g14K3s(["kubectl", "delete", "pipelinerun", "-n", CI_NAMESPACE, pipelineRun, "--ignore-not-found=true"], 60_000);
}
function v02ControlPlaneStatus(sourceCommit: string | null = getV02CachedHead()): Record<string, unknown> {
const pipelineRun = sourceCommit === null ? null : v02PipelineRunName(sourceCommit);
const bundle = v02ControlPlaneStatusBundle(pipelineRun);
function v02ControlPlaneStatus(sourceCommitInput?: string | null): Record<string, unknown> {
const bundle = v02ControlPlaneStatusBundle(sourceCommitInput);
const sections = parseShellSections(statusText(bundle));
const sourceCommit = stringOrNull(sections.sourceCommit?.stdout) ?? null;
const pipelineRun = sourceCommit === null ? null : v02PipelineRunName(sourceCommit);
const queryNowMs = timestampMs(sections.queryNow?.stdout) ?? Date.now();
const controlPlane = sections.controlPlane;
const obsoleteCronJobs = sections.obsoleteCronJobs;
const argo = sections.argo;
@@ -1191,6 +1190,8 @@ function v02ControlPlaneStatus(sourceCommit: string | null = getV02CachedHead())
"kubectl get pipelinerun -n hwlab-ci -l hwlab.pikastech.local/gitops-target=v02 -o jsonpath=<summary-fields>",
sections.recentPipelineRuns?.exitCode ?? null,
bundle.stderr,
8,
queryNowMs,
);
const activePipelineRuns = Array.isArray(recentPipelineRuns.activeItems) ? recentPipelineRuns.activeItems : [];
const [targetRevision = "", path = "", syncRevision = "", syncStatus = "", health = ""] = String(argo?.stdout ?? "").split(/\r?\n/u);
@@ -1252,12 +1253,12 @@ function v02ControlPlaneStatus(sourceCommit: string | null = getV02CachedHead())
function runV02ControlPlane(options: G14ControlPlaneOptions): Record<string, unknown> {
if (options.action === "cleanup-runs") return runControlPlaneCleanup(options);
if (options.action === "cleanup-released-pvs") return runControlPlaneReleasedPvCleanup(options);
const sourceCommit = options.action === "status" ? getV02CachedHead() : getV02Head();
const sourceCommit = options.action === "status" ? undefined : getV02Head();
if (sourceCommit === null) {
return { ok: false, command: `hwlab g14 control-plane ${options.action} --lane v02`, degradedReason: "v02-head-unresolved", workspace: V02_WORKSPACE };
}
if (options.action === "runtime-migration") return runV02RuntimeMigration(options, sourceCommit);
if (options.action === "status") return v02ControlPlaneStatus(sourceCommit);
if (options.action === "status") return v02ControlPlaneStatus();
if (options.action === "apply") {
const render = runV02RenderToTemp(sourceCommit);
if (!isCommandSuccess(render)) {
@@ -1370,11 +1371,14 @@ function runV02ControlPlane(options: G14ControlPlaneOptions): Record<string, unk
sourceCommit,
pipelineRun: v02PipelineRunName(sourceCommit),
before,
controlPlaneRefresh,
controlPlaneRefresh: compactControlPlaneRefresh(controlPlaneRefresh),
gitMirrorPreSync,
deletePipelineRun: compactCommandResult(deletePipelineRun),
createPipelineRun: createPipelineRun === null ? null : compactCommandResult(createPipelineRun),
deletePipelineRun: compactTriggerCommandResult(compactCommandResult(deletePipelineRun)),
createPipelineRun: createPipelineRun === null ? null : compactTriggerCommandResult(compactCommandResult(createPipelineRun)),
after: getPipelineRunCompact(v02PipelineRunName(sourceCommit)),
disclosure: {
fullTriggerOutput: "Use the async job stdout/stderr files from job status for full command details.",
},
};
}
@@ -1543,8 +1547,8 @@ function compactGitMirrorSync(sync: Record<string, unknown>): Record<string, unk
namespace: sync.namespace ?? GIT_MIRROR_NAMESPACE,
jobName: sync.jobName ?? null,
exitCode: nested(sync, ["result", "exitCode"]) ?? null,
stdoutTail: tailText(nested(sync, ["result", "stdout"]), 3000),
stderrTail: tailText(nested(sync, ["result", "stderr"]), 3000),
stdoutTail: tailText(nested(sync, ["result", "stdout"]), 1600),
stderrTail: tailText(nested(sync, ["result", "stderr"]), 1200),
stdoutBytes: nested(sync, ["result", "stdoutBytes"]) ?? null,
stderrBytes: nested(sync, ["result", "stderrBytes"]) ?? null,
};