cicd branch follower native closeout
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
// SPEC: PJ2026-01060703 CI/CD branch follower controller render helpers.
|
||||
// Responsibility: Kubernetes controller/reconcile Job manifests and controller bootstrap scripts.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { rootPath } from "./config";
|
||||
import { shQuote } from "./platform-infra-ops-library";
|
||||
import type { BranchFollowerRegistry, ParsedOptions } from "./cicd-types";
|
||||
|
||||
const SPEC_REF = "PJ2026-01060703";
|
||||
|
||||
export function renderControllerReconcileJob(registry: BranchFollowerRegistry, options: ParsedOptions, jobName: string, mode: { dryRun: boolean; recordState: boolean }, timeoutSeconds: number): Record<string, unknown> {
|
||||
const labels = { ...registry.controller.labels, "app.kubernetes.io/component": "cicd-reconcile-job" };
|
||||
const commandArgs = [
|
||||
"bun",
|
||||
"scripts/cli.ts",
|
||||
"cicd",
|
||||
"branch-follower",
|
||||
"run-once",
|
||||
...(options.followerId === null ? ["--all"] : ["--follower", options.followerId]),
|
||||
mode.dryRun ? "--dry-run" : "--confirm",
|
||||
"--wait",
|
||||
"--controller",
|
||||
"--config",
|
||||
"config/cicd-branch-followers.yaml",
|
||||
"--timeout-seconds",
|
||||
String(timeoutSeconds),
|
||||
...(mode.recordState ? ["--record-state"] : []),
|
||||
];
|
||||
return {
|
||||
apiVersion: "batch/v1",
|
||||
kind: "Job",
|
||||
metadata: { name: jobName, namespace: registry.controller.namespace, labels },
|
||||
spec: {
|
||||
backoffLimit: 0,
|
||||
ttlSecondsAfterFinished: 600,
|
||||
activeDeadlineSeconds: timeoutSeconds + 30,
|
||||
template: {
|
||||
metadata: { labels },
|
||||
spec: {
|
||||
restartPolicy: "Never",
|
||||
serviceAccountName: registry.controller.serviceAccountName,
|
||||
volumes: [
|
||||
{ name: "registry", configMap: { name: registry.controller.configMapName, defaultMode: 0o755 } },
|
||||
{ name: "git-mirror-cache", persistentVolumeClaim: { claimName: registry.controller.source.gitMirrorCachePvcName } },
|
||||
{ name: "git-ssh", secret: { secretName: registry.controller.source.githubSsh.secretName, defaultMode: 0o400 } },
|
||||
{ name: "work", emptyDir: {} },
|
||||
],
|
||||
containers: [
|
||||
{
|
||||
name: "reconcile",
|
||||
image: registry.controller.image,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["/bin/sh", "/etc/unidesk-cicd-branch-follower/controller-one-shot.sh"],
|
||||
args: commandArgs,
|
||||
env: [
|
||||
{ name: "UNIDESK_CONTROLLER_SOURCE_BRANCH", value: registry.controller.source.branch },
|
||||
{ name: "UNIDESK_CONTROLLER_SOURCE_REPOSITORY", value: registry.controller.source.repository },
|
||||
{ name: "UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX", value: registry.controller.source.sourceSnapshot.stageRefPrefix.replaceAll("{branch}", registry.controller.source.branch) },
|
||||
{ name: "UNIDESK_CONTROLLER_GITHUB_SSH_PRIVATE_KEY", value: `/git-ssh/${registry.controller.source.githubSsh.privateKeySecretKey}` },
|
||||
{ name: "UNIDESK_CONTROLLER_GITHUB_PROXY_HOST", value: registry.controller.source.githubSsh.proxyHost },
|
||||
{ name: "UNIDESK_CONTROLLER_GITHUB_PROXY_PORT", value: String(registry.controller.source.githubSsh.proxyPort) },
|
||||
],
|
||||
volumeMounts: [
|
||||
{ name: "registry", mountPath: "/etc/unidesk-cicd-branch-follower", readOnly: true },
|
||||
{ name: "git-mirror-cache", mountPath: "/cache" },
|
||||
{ name: "git-ssh", mountPath: "/git-ssh", readOnly: true },
|
||||
{ name: "work", mountPath: "/work" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function waitForJobShell(namespace: string, jobName: string, timeoutSeconds: number): string {
|
||||
return [
|
||||
`NAMESPACE=${shQuote(namespace)}`,
|
||||
`JOB_NAME=${shQuote(jobName)}`,
|
||||
`TIMEOUT_SECONDS=${shQuote(String(timeoutSeconds))}`,
|
||||
"export NAMESPACE JOB_NAME TIMEOUT_SECONDS",
|
||||
nativeCicdScript("wait-job.sh"),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function renderControllerManifests(registry: BranchFollowerRegistry): Record<string, unknown>[] {
|
||||
const labels = registry.controller.labels;
|
||||
const selector = labels;
|
||||
return [
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "Namespace",
|
||||
metadata: { name: registry.controller.namespace, labels },
|
||||
},
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "ServiceAccount",
|
||||
metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels },
|
||||
},
|
||||
{
|
||||
apiVersion: "rbac.authorization.k8s.io/v1",
|
||||
kind: "Role",
|
||||
metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels },
|
||||
rules: [
|
||||
{ apiGroups: [""], resources: ["configmaps", "pods", "events"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
||||
{ apiGroups: ["apps"], resources: ["deployments"], verbs: ["get", "list", "watch"] },
|
||||
{ apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] },
|
||||
{ apiGroups: ["coordination.k8s.io"], resources: ["leases"], verbs: ["get", "list", "watch", "create", "update", "patch"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
apiVersion: "rbac.authorization.k8s.io/v1",
|
||||
kind: "RoleBinding",
|
||||
metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels },
|
||||
subjects: [{ kind: "ServiceAccount", name: registry.controller.serviceAccountName, namespace: registry.controller.namespace }],
|
||||
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: registry.controller.serviceAccountName },
|
||||
},
|
||||
{
|
||||
apiVersion: "rbac.authorization.k8s.io/v1",
|
||||
kind: "ClusterRole",
|
||||
metadata: { name: registry.controller.serviceAccountName, labels },
|
||||
rules: [
|
||||
{ apiGroups: [""], resources: ["pods", "pods/log", "configmaps", "events"], verbs: ["get", "list", "watch"] },
|
||||
{ apiGroups: [""], resources: ["pods/exec"], verbs: ["create"] },
|
||||
{ apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch", "create", "delete"] },
|
||||
{ apiGroups: ["apps"], resources: ["deployments", "statefulsets"], verbs: ["get", "list", "watch"] },
|
||||
{ apiGroups: ["tekton.dev"], resources: ["pipelineruns"], verbs: ["get", "list", "watch", "create", "patch", "delete"] },
|
||||
{ apiGroups: ["tekton.dev"], resources: ["taskruns"], verbs: ["get", "list", "watch"] },
|
||||
{ apiGroups: ["argoproj.io"], resources: ["applications"], verbs: ["get", "list", "watch", "patch"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
apiVersion: "rbac.authorization.k8s.io/v1",
|
||||
kind: "ClusterRoleBinding",
|
||||
metadata: { name: registry.controller.serviceAccountName, labels },
|
||||
subjects: [{ kind: "ServiceAccount", name: registry.controller.serviceAccountName, namespace: registry.controller.namespace }],
|
||||
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "ClusterRole", name: registry.controller.serviceAccountName },
|
||||
},
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "ConfigMap",
|
||||
metadata: { name: registry.controller.configMapName, namespace: registry.controller.namespace, labels },
|
||||
data: {
|
||||
"cicd-branch-followers.yaml": registry.rawText,
|
||||
"sync-source.sh": nativeCicdScript("sync-source.sh"),
|
||||
"controller-one-shot.sh": nativeCicdScript("controller-one-shot.sh"),
|
||||
"controller-loop.sh": nativeCicdScript("controller-loop.sh"),
|
||||
"github-proxy-connect.mjs": nativeCicdScript("github-proxy-connect.mjs"),
|
||||
"git-ssh-proxy.sh": nativeCicdScript("git-ssh-proxy.sh"),
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "ConfigMap",
|
||||
metadata: { name: registry.controller.stateConfigMapName, namespace: registry.controller.namespace, labels },
|
||||
data: {
|
||||
_createdAt: new Date().toISOString(),
|
||||
_specRef: SPEC_REF,
|
||||
_registrySha256: registry.rawSha256,
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: "coordination.k8s.io/v1",
|
||||
kind: "Lease",
|
||||
metadata: { name: registry.controller.leaseName, namespace: registry.controller.namespace, labels },
|
||||
spec: { holderIdentity: "unidesk-cicd-branch-follower", leaseDurationSeconds: Math.max(30, registry.controller.loop.reconcileTimeoutSeconds + 30) },
|
||||
},
|
||||
{
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
metadata: { name: registry.controller.deploymentName, namespace: registry.controller.namespace, labels },
|
||||
spec: {
|
||||
replicas: 1,
|
||||
selector: { matchLabels: selector },
|
||||
template: {
|
||||
metadata: {
|
||||
labels: selector,
|
||||
annotations: {
|
||||
"unidesk.pikapython.com/spec-ref": SPEC_REF,
|
||||
"unidesk.pikapython.com/registry-sha256": registry.rawSha256,
|
||||
"unidesk.pikapython.com/host-worktree-authority": "false",
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
serviceAccountName: registry.controller.serviceAccountName,
|
||||
terminationGracePeriodSeconds: 30,
|
||||
volumes: [
|
||||
{ name: "registry", configMap: { name: registry.controller.configMapName, defaultMode: 0o755 } },
|
||||
{ name: "git-mirror-cache", persistentVolumeClaim: { claimName: registry.controller.source.gitMirrorCachePvcName } },
|
||||
{ name: "git-ssh", secret: { secretName: registry.controller.source.githubSsh.secretName, defaultMode: 0o400 } },
|
||||
{ name: "work", emptyDir: {} },
|
||||
],
|
||||
containers: [
|
||||
{
|
||||
name: "controller",
|
||||
image: registry.controller.image,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["/bin/sh", "/etc/unidesk-cicd-branch-follower/controller-loop.sh"],
|
||||
env: [
|
||||
{ name: "UNIDESK_CICD_BRANCH_FOLLOWER_INTERVAL_SECONDS", value: String(registry.controller.loop.intervalSeconds) },
|
||||
{ name: "UNIDESK_CICD_BRANCH_FOLLOWER_TIMEOUT_SECONDS", value: String(registry.controller.loop.reconcileTimeoutSeconds) },
|
||||
{ name: "UNIDESK_CONTROLLER_GIT_MIRROR_READ_URL", value: registry.controller.source.gitMirrorReadUrl },
|
||||
{ name: "UNIDESK_CONTROLLER_SOURCE_BRANCH", value: registry.controller.source.branch },
|
||||
{ name: "UNIDESK_CONTROLLER_SOURCE_REPOSITORY", value: registry.controller.source.repository },
|
||||
{ name: "UNIDESK_CONTROLLER_SOURCE_SNAPSHOT_PREFIX", value: registry.controller.source.sourceSnapshot.stageRefPrefix.replaceAll("{branch}", registry.controller.source.branch) },
|
||||
{ name: "UNIDESK_CONTROLLER_GITHUB_SSH_PRIVATE_KEY", value: `/git-ssh/${registry.controller.source.githubSsh.privateKeySecretKey}` },
|
||||
{ name: "UNIDESK_CONTROLLER_GITHUB_PROXY_HOST", value: registry.controller.source.githubSsh.proxyHost },
|
||||
{ name: "UNIDESK_CONTROLLER_GITHUB_PROXY_PORT", value: String(registry.controller.source.githubSsh.proxyPort) },
|
||||
],
|
||||
volumeMounts: [
|
||||
{ name: "registry", mountPath: "/etc/unidesk-cicd-branch-follower", readOnly: true },
|
||||
{ name: "git-mirror-cache", mountPath: "/cache" },
|
||||
{ name: "git-ssh", mountPath: "/git-ssh", readOnly: true },
|
||||
{ name: "work", mountPath: "/work" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function nativeCicdScript(name: string): string {
|
||||
return readFileSync(rootPath("scripts/native/cicd", name), "utf8");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPEC: PJ2026-01060703 CI/CD branch follower native Kubernetes helpers.
|
||||
// Responsibility: submit/probe Kubernetes Jobs and Tekton PipelineRuns via file-backed native scripts.
|
||||
import { repoRoot, rootPath } from "./config";
|
||||
import { runCommand, type CommandResult } from "./command";
|
||||
import type { NativeK8sJobResult } from "./cicd-types";
|
||||
|
||||
const NATIVE_SCRIPT_DIR = "scripts/native/cicd";
|
||||
|
||||
export function runNativeTektonPipelineRun(namespace: string, pipelineRun: string, manifest: Record<string, unknown>, wait: boolean, timeoutSeconds: number): CommandResult {
|
||||
return runCommand(["node", rootPath(NATIVE_SCRIPT_DIR, "submit-pipelinerun.mjs")], repoRoot, {
|
||||
input: Buffer.from(JSON.stringify(manifest), "utf8").toString("base64"),
|
||||
timeoutMs: Math.max(5, timeoutSeconds + 10) * 1000,
|
||||
env: {
|
||||
...process.env,
|
||||
NAMESPACE: namespace,
|
||||
PIPELINERUN: pipelineRun,
|
||||
WAIT: wait ? "true" : "false",
|
||||
TIMEOUT_SECONDS: String(timeoutSeconds),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runNativeK8sJob(namespace: string, jobName: string, manifest: Record<string, unknown>, timeoutSeconds: number, logContainer: string): NativeK8sJobResult {
|
||||
const result = runCommand(["node", rootPath(NATIVE_SCRIPT_DIR, "native-job.mjs")], repoRoot, {
|
||||
input: Buffer.from(JSON.stringify(manifest), "utf8").toString("base64"),
|
||||
timeoutMs: Math.max(5, timeoutSeconds + 10) * 1000,
|
||||
env: {
|
||||
...process.env,
|
||||
NAMESPACE: namespace,
|
||||
JOB_NAME: jobName,
|
||||
LOG_CONTAINER: logContainer,
|
||||
TIMEOUT_SECONDS: String(timeoutSeconds),
|
||||
},
|
||||
});
|
||||
const parsed = result.exitCode === 0 ? parseJsonObject(result.stdout) : null;
|
||||
return {
|
||||
ok: result.exitCode === 0 && parsed?.ok === true,
|
||||
completed: parsed?.completed === true,
|
||||
failed: parsed?.failed === true || result.exitCode !== 0,
|
||||
timedOut: parsed?.timedOut === true || result.timedOut,
|
||||
created: parsed?.created === true,
|
||||
reused: parsed?.reused === true,
|
||||
jobName,
|
||||
namespace,
|
||||
polls: numberOrNull(parsed?.polls) ?? 0,
|
||||
elapsedMs: numberOrNull(parsed?.elapsedMs) ?? 0,
|
||||
logsTail: stringOrNull(parsed?.logsTail),
|
||||
conditionReason: stringOrNull(parsed?.conditionReason),
|
||||
conditionMessage: stringOrNull(parsed?.conditionMessage) ?? (result.exitCode === 0 ? null : tailText(result.stderr || result.stdout, 500)),
|
||||
statusAuthority: "kubernetes-api-serviceaccount",
|
||||
parsedDownstreamCliOutput: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function tailText(text: string, maxChars: number): string {
|
||||
return text.length <= maxChars ? text : text.slice(text.length - maxChars);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// SPEC: PJ2026-01060703 CI/CD branch follower shared types.
|
||||
// Responsibility: type contracts shared by branch follower entry, controller render, and native K8s helpers.
|
||||
|
||||
export type OutputMode = "human" | "json" | "yaml";
|
||||
export type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "cleanup-state" | "events" | "logs";
|
||||
export type BranchFollowerPhase =
|
||||
| "Observed"
|
||||
| "Noop"
|
||||
| "PendingTrigger"
|
||||
| "Triggering"
|
||||
| "ClosingOut"
|
||||
| "Succeeded"
|
||||
| "Failed"
|
||||
| "Superseded"
|
||||
| "Blocked"
|
||||
| "Skipped";
|
||||
|
||||
export interface ParsedOptions {
|
||||
action: BranchFollowerAction;
|
||||
configPath: string;
|
||||
followerId: string | null;
|
||||
all: boolean;
|
||||
confirm: boolean;
|
||||
dryRun: boolean;
|
||||
wait: boolean;
|
||||
controller: boolean;
|
||||
live: boolean;
|
||||
noLive: boolean;
|
||||
full: boolean;
|
||||
raw: boolean;
|
||||
recordState: boolean;
|
||||
output: OutputMode;
|
||||
limit: number;
|
||||
tailBytes: number;
|
||||
timeoutSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface CommandSpec {
|
||||
argv: string[];
|
||||
timeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface FollowerSpec {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
adapter: string;
|
||||
description: string;
|
||||
source: {
|
||||
repository: string;
|
||||
branch: string;
|
||||
branchRef: string;
|
||||
authorityRef: string;
|
||||
snapshotPrefix: string;
|
||||
snapshotRef: string;
|
||||
};
|
||||
target: {
|
||||
node: string;
|
||||
lane: string;
|
||||
namespace: string;
|
||||
sentinel: string | null;
|
||||
configRefs: Record<string, string>;
|
||||
};
|
||||
budgets: {
|
||||
endToEndSeconds: number;
|
||||
statusSeconds: number;
|
||||
triggerSeconds: number;
|
||||
sourceSyncSeconds: number;
|
||||
};
|
||||
commands: {
|
||||
plan: CommandSpec;
|
||||
status: CommandSpec;
|
||||
trigger: CommandSpec;
|
||||
events: CommandSpec;
|
||||
logs: CommandSpec;
|
||||
};
|
||||
nativeStatus: NativeStatusSpec;
|
||||
closeoutChecks: string[];
|
||||
}
|
||||
|
||||
export interface NativeStatusSpec {
|
||||
source: {
|
||||
gitMirrorReadUrl: string;
|
||||
gitMirrorNamespace: string;
|
||||
gitMirrorDeployment: string;
|
||||
repoPath: string;
|
||||
};
|
||||
tekton: {
|
||||
namespace: string;
|
||||
pipelineRunPrefix: string;
|
||||
} | null;
|
||||
argo: {
|
||||
namespace: string;
|
||||
application: string;
|
||||
} | null;
|
||||
runtime: {
|
||||
namespace: string;
|
||||
workloads: NativeWorkloadSpec[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface NativeWorkloadSpec {
|
||||
kind: "Deployment" | "StatefulSet";
|
||||
name: string;
|
||||
sourceCommit: {
|
||||
labels: string[];
|
||||
annotations: string[];
|
||||
podLabels: string[];
|
||||
podAnnotations: string[];
|
||||
env: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ControllerSpec {
|
||||
namespace: string;
|
||||
kubeRoute: string;
|
||||
fieldManager: string;
|
||||
serviceAccountName: string;
|
||||
deploymentName: string;
|
||||
configMapName: string;
|
||||
stateConfigMapName: string;
|
||||
leaseName: string;
|
||||
image: string;
|
||||
labels: Record<string, string>;
|
||||
source: {
|
||||
repository: string;
|
||||
branch: string;
|
||||
gitMirrorReadUrl: string;
|
||||
gitMirrorCachePvcName: string;
|
||||
githubSsh: {
|
||||
secretName: string;
|
||||
privateKeySecretKey: string;
|
||||
proxyHost: string;
|
||||
proxyPort: number;
|
||||
};
|
||||
sourceAuthority: {
|
||||
mode: string;
|
||||
resolver: string;
|
||||
allowHostGit: boolean;
|
||||
allowHostWorkspace: boolean;
|
||||
allowGithubDirectInPipeline: boolean;
|
||||
};
|
||||
sourceSnapshot: {
|
||||
stageRefPrefix: string;
|
||||
missingObjectPolicy: string;
|
||||
refreshPolicy: string;
|
||||
};
|
||||
};
|
||||
loop: {
|
||||
intervalSeconds: number;
|
||||
reconcileTimeoutSeconds: number;
|
||||
};
|
||||
budgets: {
|
||||
applyWaitSeconds: number;
|
||||
statusSeconds: number;
|
||||
runOnceSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BranchFollowerRegistry {
|
||||
path: string;
|
||||
rawText: string;
|
||||
rawSha256: string;
|
||||
metadata: {
|
||||
id: string;
|
||||
owner: string;
|
||||
specRef: string;
|
||||
version: string;
|
||||
};
|
||||
controller: ControllerSpec;
|
||||
followers: FollowerSpec[];
|
||||
}
|
||||
|
||||
export interface AdapterSummary {
|
||||
ok: boolean;
|
||||
command: string;
|
||||
exitCode: number | null;
|
||||
timedOut: boolean;
|
||||
observedSha: string | null;
|
||||
targetSha: string | null;
|
||||
lastTriggeredSha: string | null;
|
||||
lastSucceededSha: string | null;
|
||||
pipelineRun: string | null;
|
||||
pipelineRunPresent: boolean | null;
|
||||
inFlightJob: string | null;
|
||||
aligned: boolean | null;
|
||||
phase: BranchFollowerPhase;
|
||||
message: string;
|
||||
payload: Record<string, unknown> | null;
|
||||
stderrTail: string;
|
||||
stdoutTail: string;
|
||||
}
|
||||
|
||||
export interface NativeObjectBundle {
|
||||
ok: boolean;
|
||||
source: Record<string, unknown> | null;
|
||||
gitMirror: Record<string, unknown> | null;
|
||||
pipelineRun: Record<string, unknown> | null;
|
||||
taskRuns: Record<string, unknown> | null;
|
||||
planArtifacts: Record<string, unknown> | null;
|
||||
argoApplication: Record<string, unknown> | null;
|
||||
workloads: Record<string, unknown>[];
|
||||
errors: string[];
|
||||
exitCode: number | null;
|
||||
timedOut: boolean;
|
||||
stdoutTail: string;
|
||||
stderrTail: string;
|
||||
}
|
||||
|
||||
export interface TriggerResult {
|
||||
ok: boolean;
|
||||
completed: boolean;
|
||||
message: string;
|
||||
jobId: string | null;
|
||||
command: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NativeCloseoutWaitResult {
|
||||
ok: boolean;
|
||||
completed: boolean;
|
||||
timedOut: boolean;
|
||||
polls: number;
|
||||
elapsedMs: number;
|
||||
refresh: Record<string, unknown> | null;
|
||||
summary: Record<string, unknown> | null;
|
||||
statusAuthority: "k8s-native";
|
||||
parsedDownstreamCliOutput: false;
|
||||
}
|
||||
|
||||
export interface NativeK8sJobResult {
|
||||
ok: boolean;
|
||||
completed: boolean;
|
||||
failed: boolean;
|
||||
timedOut: boolean;
|
||||
created: boolean;
|
||||
reused: boolean;
|
||||
jobName: string;
|
||||
namespace: string;
|
||||
polls: number;
|
||||
elapsedMs: number;
|
||||
logsTail: string | null;
|
||||
conditionReason: string | null;
|
||||
conditionMessage: string | null;
|
||||
statusAuthority: "kubernetes-api-serviceaccount";
|
||||
parsedDownstreamCliOutput: false;
|
||||
}
|
||||
|
||||
export interface FollowerState {
|
||||
id: string;
|
||||
adapter: string;
|
||||
enabled: boolean;
|
||||
phase: BranchFollowerPhase;
|
||||
source: {
|
||||
repository: string;
|
||||
branch: string;
|
||||
branchRef: string;
|
||||
snapshotPrefix: string;
|
||||
observedSha: string | null;
|
||||
};
|
||||
target: {
|
||||
node: string;
|
||||
lane: string;
|
||||
namespace: string;
|
||||
sentinel: string | null;
|
||||
targetSha: string | null;
|
||||
};
|
||||
lastTriggeredSha: string | null;
|
||||
lastSucceededSha: string | null;
|
||||
pipelineRun: string | null;
|
||||
inFlightJob: string | null;
|
||||
budgetSource: Record<string, number>;
|
||||
controller: {
|
||||
mode: "local-cli" | "k8s-controller";
|
||||
stateConfigMap: string;
|
||||
leaseName: string;
|
||||
};
|
||||
decision: string;
|
||||
dryRun: boolean;
|
||||
updatedAt: string;
|
||||
warnings: string[];
|
||||
next: Record<string, string>;
|
||||
command?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface K8sStateRead {
|
||||
ok: boolean;
|
||||
stateByFollower: Record<string, Record<string, unknown>>;
|
||||
stateConfigMapPresent: boolean;
|
||||
deployment: Record<string, unknown> | null;
|
||||
lease: Record<string, unknown> | null;
|
||||
pods: Record<string, unknown> | null;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface K8sFollowerStateRead {
|
||||
ok: boolean;
|
||||
stateByFollower: Record<string, Record<string, unknown>>;
|
||||
present: boolean;
|
||||
error: string;
|
||||
}
|
||||
+217
-1056
File diff suppressed because it is too large
Load Diff
@@ -60,7 +60,7 @@ export function ghHelp(): unknown {
|
||||
"bun scripts/cli.ts gh pr comment edit <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for pr comment update]",
|
||||
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch|--keep-branch] [--sync-node NODE]... [--skip-local-closeout] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr delete <number> [unsupported: use close]",
|
||||
],
|
||||
defaults: { repo: DEFAULT_REPO },
|
||||
@@ -105,7 +105,7 @@ export function ghHelp(): unknown {
|
||||
"PR preflight/closeout accept the same owner/repo#number shorthand as PR view/read 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 for explicit diagnosis only. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and an explicit read-only policy. It is not a required step before gh pr merge. Use --full or --raw to include all fetched status contexts.",
|
||||
"PR merge is the one-command guarded write path: it reads closeout metadata itself, retries GitHub UNKNOWN/null mergeability with YAML-configured exponential backoff, 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.",
|
||||
"PR merge is the one-command guarded write path: it reads closeout metadata itself, retries GitHub UNKNOWN/null mergeability with YAML-configured exponential backoff, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. It defaults to deleting the merged same-repo head branch, cleaning the matching local .worktree when clean, and fast-forwarding the local main worktree on the PR base branch; --sync-node NODE additionally runs mapped node source-workspace sync. Use --dry-run to see the exact merge and closeout plan without writing.",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -168,6 +168,7 @@ export function ghScopedHelpNotes(tokens: string[]): string[] {
|
||||
} else if (key === "pr merge") {
|
||||
notes.push("PR merge is one-command guarded: it performs the readiness check itself; `gh pr preflight` is optional read-only diagnosis, not a required first step.");
|
||||
notes.push("When GitHub reports mergeability as UNKNOWN/null, merge automatically retries with YAML-configured exponential backoff and shows retry attempts as N/M.");
|
||||
notes.push("After merge it deletes the same-repo head branch by default, removes a clean matching local `.worktree`, and fast-forwards the local main worktree; use `--keep-branch` or `--skip-local-closeout` only when intentionally preserving state.");
|
||||
} else if (key === "pr review-plan" || key === "pr diff") {
|
||||
notes.push("Use `pr review-plan` first for a bounded changed-file index with per-file drill-down commands.");
|
||||
notes.push("Use `pr diff <number> --file <path> [--hunk N]` for bounded patch review; full patch disclosure requires explicit --full or --raw.");
|
||||
|
||||
@@ -321,6 +321,9 @@ export function stdinAliasFileOption(args: string[], fileOption: string, stdinFl
|
||||
export function parseOptions(args: string[]): GitHubOptions {
|
||||
validateKnownOptions(args);
|
||||
const [top, sub] = args;
|
||||
if (hasFlag(args, "--delete-branch") && hasFlag(args, "--keep-branch")) {
|
||||
throw new Error("gh pr merge accepts either --delete-branch or --keep-branch, not both");
|
||||
}
|
||||
const requestedJsonFields = commaListOption(args, "--json");
|
||||
const limitMax = top === "pr" && (sub === "files" || sub === "diff")
|
||||
? MAX_PR_FILES_LIMIT
|
||||
@@ -372,7 +375,9 @@ export function parseOptions(args: string[]): GitHubOptions {
|
||||
boardGithubStatus: parseBoardGithubStatus(args),
|
||||
boardRowUpsertValues: parseBoardRowUpsertValues(args),
|
||||
mergeMethod: parsePullRequestMergeMethod(args),
|
||||
deleteBranch: hasFlag(args, "--delete-branch"),
|
||||
deleteBranch: top === "pr" && sub === "merge" ? !hasFlag(args, "--keep-branch") : hasFlag(args, "--delete-branch"),
|
||||
localCloseout: !hasFlag(args, "--skip-local-closeout"),
|
||||
syncNodes: optionValues(args, "--sync-node").map((value) => value.trim()).filter((value) => value.length > 0),
|
||||
attachmentSelector: optionValue(args, "--attachment"),
|
||||
outputPath: optionValue(args, "--output"),
|
||||
filePath: optionValue(args, "--file"),
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPEC: PJ2026-01060703 GitHub PR merge closeout.
|
||||
// Responsibility: guarded local/node worktree closeout after a successful PR merge.
|
||||
|
||||
import { relative, resolve, sep } from "node:path";
|
||||
import { repoRoot } from "../config";
|
||||
import { runCommand, type CommandResult } from "../command";
|
||||
import type { GitHubOptions, GitHubPullRequest } from "./types";
|
||||
|
||||
interface WorktreeEntry {
|
||||
path: string;
|
||||
branch: string | null;
|
||||
head: string | null;
|
||||
}
|
||||
|
||||
export function runPrMergeCloseout(repo: string, pr: GitHubPullRequest, options: GitHubOptions): Record<string, unknown> {
|
||||
const headRef = pr.head?.ref ?? null;
|
||||
const baseRef = pr.base?.ref ?? null;
|
||||
const localEnabled = options.localCloseout;
|
||||
return {
|
||||
ok: true,
|
||||
valuesPrinted: false,
|
||||
headRef,
|
||||
baseRef,
|
||||
localWorktree: localEnabled ? cleanupHeadWorktree(headRef, options.dryRun) : skipped("local-closeout-disabled"),
|
||||
mainWorktree: localEnabled ? syncLocalMainWorktree(repo, baseRef, options.dryRun) : skipped("local-closeout-disabled"),
|
||||
nodeSyncs: syncRequestedNodes(repo, baseRef, options.syncNodes, options.dryRun),
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupHeadWorktree(headRef: string | null, dryRun: boolean): Record<string, unknown> {
|
||||
if (headRef === null || headRef.length === 0) return skipped("head-ref-missing");
|
||||
const list = git(["worktree", "list", "--porcelain"], 10_000);
|
||||
if (list.exitCode !== 0) return failed("worktree-list-failed", list);
|
||||
const entries = parseWorktreeList(list.stdout);
|
||||
const matches = entries.filter((entry) => entry.branch === `refs/heads/${headRef}` && isManagedTaskWorktree(entry.path));
|
||||
if (matches.length === 0) return skipped("local-head-worktree-not-found", { headRef });
|
||||
const removals = matches.map((entry) => removeWorktree(entry.path, dryRun));
|
||||
const branchDelete = deleteLocalBranch(headRef, dryRun);
|
||||
return {
|
||||
ok: removals.every((item) => item.ok === true || item.planned === true) && (branchDelete.ok === true || branchDelete.planned === true || branchDelete.skipped === true),
|
||||
headRef,
|
||||
worktrees: removals,
|
||||
branchDelete,
|
||||
};
|
||||
}
|
||||
|
||||
function removeWorktree(path: string, dryRun: boolean): Record<string, unknown> {
|
||||
const status = git(["-C", path, "status", "--porcelain"], 10_000);
|
||||
if (status.exitCode !== 0) return failed("worktree-status-failed", status, { path });
|
||||
if (status.stdout.trim().length > 0) return skipped("worktree-dirty", { path, porcelainLines: status.stdout.trim().split(/\r?\n/u).slice(0, 20) });
|
||||
if (dryRun) return { planned: true, action: "git-worktree-remove", path };
|
||||
const removed = git(["worktree", "remove", path], 30_000);
|
||||
return removed.exitCode === 0 ? { ok: true, action: "git-worktree-remove", path } : failed("worktree-remove-failed", removed, { path });
|
||||
}
|
||||
|
||||
function deleteLocalBranch(headRef: string, dryRun: boolean): Record<string, unknown> {
|
||||
const list = git(["branch", "--list", headRef], 10_000);
|
||||
if (list.exitCode !== 0) return failed("branch-list-failed", list, { headRef });
|
||||
if (list.stdout.trim().length === 0) return skipped("local-branch-not-found", { headRef });
|
||||
if (dryRun) return { planned: true, action: "git-branch-delete", branch: headRef };
|
||||
const deleted = git(["branch", "-d", headRef], 20_000);
|
||||
return deleted.exitCode === 0 ? { ok: true, action: "git-branch-delete", branch: headRef } : failed("branch-delete-failed", deleted, { branch: headRef });
|
||||
}
|
||||
|
||||
function syncLocalMainWorktree(repo: string, baseRef: string | null, dryRun: boolean): Record<string, unknown> {
|
||||
if (baseRef === null || baseRef.length === 0) return skipped("base-ref-missing");
|
||||
const remote = git(["remote", "get-url", "origin"], 10_000);
|
||||
if (remote.exitCode !== 0) return failed("remote-read-failed", remote);
|
||||
if (!remoteMatchesRepo(remote.stdout.trim(), repo)) return skipped("local-repo-mismatch", { repo, origin: redactRemote(remote.stdout.trim()) });
|
||||
const current = git(["rev-parse", "--abbrev-ref", "HEAD"], 10_000);
|
||||
if (current.exitCode !== 0) return failed("current-branch-read-failed", current);
|
||||
const currentBranch = current.stdout.trim();
|
||||
if (currentBranch !== baseRef) return skipped("main-worktree-on-different-branch", { currentBranch, baseRef });
|
||||
if (dryRun) return { planned: true, action: "git-fetch-merge-ff-only", path: repoRoot, branch: baseRef };
|
||||
|
||||
const status = git(["status", "--porcelain"], 10_000);
|
||||
if (status.exitCode !== 0) return failed("main-worktree-status-failed", status);
|
||||
const hadDirty = status.stdout.trim().length > 0;
|
||||
const stash = hadDirty ? git(["stash", "push", "-u", "-m", `unidesk-gh-pr-merge-closeout ${repo} ${baseRef}`], 30_000) : null;
|
||||
if (stash !== null && stash.exitCode !== 0) return failed("main-worktree-stash-failed", stash, { branch: baseRef });
|
||||
|
||||
const fetched = git(["fetch", "origin", baseRef], 60_000);
|
||||
const merged = fetched.exitCode === 0 ? git(["merge", "--ff-only", `origin/${baseRef}`], 60_000) : null;
|
||||
const applied = stash !== null ? git(["stash", "apply"], 60_000) : null;
|
||||
const ok = fetched.exitCode === 0 && merged?.exitCode === 0 && (applied === null || applied.exitCode === 0);
|
||||
return {
|
||||
ok,
|
||||
action: "git-fetch-merge-ff-only",
|
||||
path: repoRoot,
|
||||
branch: baseRef,
|
||||
dirtyPreservedByStash: hadDirty,
|
||||
fetch: commandSummary(fetched),
|
||||
merge: merged === null ? null : commandSummary(merged),
|
||||
stashApply: applied === null ? null : commandSummary(applied),
|
||||
};
|
||||
}
|
||||
|
||||
function syncRequestedNodes(repo: string, baseRef: string | null, nodes: string[], dryRun: boolean): Record<string, unknown>[] {
|
||||
return nodes.map((node) => syncRequestedNode(repo, baseRef, node, dryRun));
|
||||
}
|
||||
|
||||
function syncRequestedNode(repo: string, baseRef: string | null, node: string, dryRun: boolean): Record<string, unknown> {
|
||||
const command = nodeSyncCommand(repo, baseRef, node);
|
||||
if (command === null) return skipped("node-sync-unsupported", { repo, baseRef, node });
|
||||
if (dryRun) return { planned: true, node, command: command.join(" ") };
|
||||
const result = runCommand(command, repoRoot, { timeoutMs: 120_000 });
|
||||
return result.exitCode === 0 ? { ok: true, node, command: command.join(" ") } : failed("node-sync-failed", result, { node, command: command.join(" ") });
|
||||
}
|
||||
|
||||
function nodeSyncCommand(repo: string, baseRef: string | null, node: string): string[] | null {
|
||||
if (repo.toLowerCase() === "pikastech/hwlab" && baseRef === "v0.3") {
|
||||
return ["bun", "scripts/cli.ts", "hwlab", "nodes", "control-plane", "source-workspace", "sync", "--node", node.toUpperCase(), "--lane", "v03", "--confirm"];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseWorktreeList(text: string): WorktreeEntry[] {
|
||||
const entries: WorktreeEntry[] = [];
|
||||
let current: WorktreeEntry | null = null;
|
||||
for (const line of text.split(/\r?\n/u)) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
if (current !== null) entries.push(current);
|
||||
current = { path: line.slice("worktree ".length), branch: null, head: null };
|
||||
} else if (line.startsWith("branch ") && current !== null) {
|
||||
current.branch = line.slice("branch ".length);
|
||||
} else if (line.startsWith("HEAD ") && current !== null) {
|
||||
current.head = line.slice("HEAD ".length);
|
||||
}
|
||||
}
|
||||
if (current !== null) entries.push(current);
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isManagedTaskWorktree(path: string): boolean {
|
||||
const rel = relative(repoRoot, resolve(path));
|
||||
return rel === ".worktree" || rel.startsWith(`.worktree${sep}`);
|
||||
}
|
||||
|
||||
function git(args: string[], timeoutMs: number): CommandResult {
|
||||
return runCommand(["git", ...args], repoRoot, { timeoutMs });
|
||||
}
|
||||
|
||||
function remoteMatchesRepo(remote: string, repo: string): boolean {
|
||||
const normalized = remote
|
||||
.replace(/^git@github\.com:/u, "")
|
||||
.replace(/^https:\/\/github\.com\//u, "")
|
||||
.replace(/\.git$/u, "")
|
||||
.toLowerCase();
|
||||
return normalized === repo.toLowerCase();
|
||||
}
|
||||
|
||||
function redactRemote(remote: string): string {
|
||||
return remote.replace(/:\/\/[^/@]+@/u, "://<redacted>@");
|
||||
}
|
||||
|
||||
function skipped(reason: string, details: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { ok: true, skipped: true, skippedReason: reason, ...details };
|
||||
}
|
||||
|
||||
function failed(reason: string, result: CommandResult, details: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { ok: false, degradedReason: reason, ...details, command: result.command.join(" "), exitCode: result.exitCode, timedOut: result.timedOut, stderrTail: tail(result.stderr), stdoutTail: tail(result.stdout) };
|
||||
}
|
||||
|
||||
function commandSummary(result: CommandResult): Record<string, unknown> {
|
||||
return { exitCode: result.exitCode, timedOut: result.timedOut, stderrTail: tail(result.stderr), stdoutTail: tail(result.stdout) };
|
||||
}
|
||||
|
||||
function tail(text: string): string {
|
||||
return text.trim().split(/\r?\n/u).slice(-8).join("\n").slice(-1000);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { repoParts, resolveToken } from "./auth-and-safety";
|
||||
import { authStatus } from "./auth-pr-read";
|
||||
import { authRequired, commandError, errorPayload, githubRequest, isGitHubError, runnerDisposition, validationError } from "./client";
|
||||
import { isRecord } from "./notify-claudeqq";
|
||||
import { runPrMergeCloseout } from "./pr-merge-closeout";
|
||||
import { compactAuthCapability, deleteHeadBranchAfterMerge, loadPrMergeUnknownRetryConfig, mergeabilityHasUnknownPending, nextPrMergeRetryDelayMs, prCloseoutMetadata, prCloseoutSummary, preflightPullRequestSummary, prGraphqlMetadata, prMergeRetryCommand, prMetadataSummary, prPreflightPolicy, prSummary, sleepMs, statusRollupSummary } from "./pr-summary";
|
||||
import { ghShort, ghTable, ghText } from "./render";
|
||||
import { UNIDESK_CLI_CONFIG_PATH } from "./types";
|
||||
@@ -22,15 +23,19 @@ export async function prMerge(repo: string, token: string, number: number, optio
|
||||
if (isGitHubError(pr)) return commandError("pr merge", repo, pr, { number, phase: "fetch-pr" });
|
||||
const summary = prSummary(pr);
|
||||
if (summary.merged === true) {
|
||||
const branchDeletion = options.deleteBranch && !options.dryRun ? await deleteHeadBranchAfterMerge(repo, token, pr) : { attempted: false, skippedReason: options.deleteBranch ? "dry-run" : "keep-branch-requested" };
|
||||
const closeout = runPrMergeCloseout(repo, pr, options);
|
||||
return withPrMergeRendered({
|
||||
ok: true,
|
||||
command: "pr merge",
|
||||
repo,
|
||||
number,
|
||||
method: options.mergeMethod,
|
||||
deleteBranch: options.deleteBranch,
|
||||
alreadyMerged: true,
|
||||
pullRequest: summary,
|
||||
branchDeletion: { attempted: false, skippedReason: "already-merged" },
|
||||
branchDeletion,
|
||||
closeout,
|
||||
rest: true,
|
||||
});
|
||||
}
|
||||
@@ -97,6 +102,7 @@ export async function prMerge(repo: string, token: string, number: number, optio
|
||||
mergeability,
|
||||
statusChecks,
|
||||
retry,
|
||||
closeout: runPrMergeCloseout(repo, pr, options),
|
||||
});
|
||||
}
|
||||
const merged = await githubRequest<Record<string, unknown>>(token, "PUT", `/repos/${owner}/${name}/pulls/${number}/merge`, {
|
||||
@@ -105,19 +111,22 @@ export async function prMerge(repo: string, token: string, number: number, optio
|
||||
if (isGitHubError(merged)) return commandError("pr merge", repo, merged, { number, phase: "merge", method: options.mergeMethod, pullRequest: summary });
|
||||
const after = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||||
if (isGitHubError(after)) return commandError("pr merge", repo, after, { number, phase: "fetch-after-merge", mergeResult: merged });
|
||||
const branchDeletion = options.deleteBranch ? await deleteHeadBranchAfterMerge(repo, token, after) : { attempted: false, skippedReason: "delete-branch-not-requested" };
|
||||
const branchDeletion = options.deleteBranch ? await deleteHeadBranchAfterMerge(repo, token, after) : { attempted: false, skippedReason: "keep-branch-requested" };
|
||||
const closeout = runPrMergeCloseout(repo, after, options);
|
||||
return withPrMergeRendered({
|
||||
ok: true,
|
||||
command: "pr merge",
|
||||
repo,
|
||||
number,
|
||||
method: options.mergeMethod,
|
||||
deleteBranch: options.deleteBranch,
|
||||
mergeResult: merged,
|
||||
pullRequest: prSummary(after),
|
||||
mergeability,
|
||||
statusChecks,
|
||||
retry,
|
||||
branchDeletion,
|
||||
closeout,
|
||||
rest: true,
|
||||
});
|
||||
}
|
||||
@@ -149,6 +158,10 @@ export function renderPrMergeTable(result: GitHubCommandResult, fallbackDetails?
|
||||
: {};
|
||||
const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {};
|
||||
const branchDeletion = isRecord(result.branchDeletion) ? result.branchDeletion : {};
|
||||
const closeout = isRecord(result.closeout) ? result.closeout : {};
|
||||
const localWorktree = isRecord(closeout.localWorktree) ? closeout.localWorktree : {};
|
||||
const mainWorktree = isRecord(closeout.mainWorktree) ? closeout.mainWorktree : {};
|
||||
const nodeSyncs = Array.isArray(closeout.nodeSyncs) ? closeout.nodeSyncs.filter(isRecord) : [];
|
||||
const retry = isRecord(result.retry)
|
||||
? result.retry
|
||||
: isRecord(details.retry)
|
||||
@@ -186,6 +199,7 @@ export function renderPrMergeTable(result: GitHubCommandResult, fallbackDetails?
|
||||
` blockers=${blockers.length === 0 ? "-" : blockers.join(",")} pending=${pending.length === 0 ? "-" : pending.join(",")}`,
|
||||
` retry=${retry.attempts !== undefined && retry.maxAttempts !== undefined ? `${ghText(retry.attempts)}/${ghText(retry.maxAttempts)} exhausted=${ghText(retry.exhausted)}` : "-"}`,
|
||||
` branchDeletion=${ghText(branchDeletion.ok ?? branchDeletion.skippedReason ?? branchDeletion.attempted)}`,
|
||||
` closeout localWorktree=${closeoutCell(localWorktree)} mainWorktree=${closeoutCell(mainWorktree)} nodeSyncs=${nodeSyncs.length === 0 ? "-" : nodeSyncs.map(closeoutCell).join(",")}`,
|
||||
"",
|
||||
"Next:",
|
||||
];
|
||||
@@ -200,6 +214,14 @@ export function renderPrMergeTable(result: GitHubCommandResult, fallbackDetails?
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function closeoutCell(value: Record<string, unknown>): string {
|
||||
if (value.planned === true) return "planned";
|
||||
if (value.skipped === true) return `skipped:${ghText(value.skippedReason)}`;
|
||||
if (value.ok === true) return "ok";
|
||||
if (value.ok === false) return `failed:${ghText(value.degradedReason)}`;
|
||||
return "-";
|
||||
}
|
||||
|
||||
export async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise<GitHubCommandResult> {
|
||||
const auth = await authStatus(repo);
|
||||
const authCapability = compactAuthCapability(auth);
|
||||
|
||||
@@ -480,7 +480,7 @@ export function prMergeRetryCommand(repo: string, number: unknown, method: unkno
|
||||
"--repo",
|
||||
repo,
|
||||
prMergeMethodFlag(method),
|
||||
deleteBranch === true ? "--delete-branch" : "",
|
||||
deleteBranch === false ? "--keep-branch" : "--delete-branch",
|
||||
].filter((item) => item.length > 0).join(" ");
|
||||
}
|
||||
|
||||
|
||||
@@ -94,10 +94,10 @@ export const GH_VALUE_OPTIONS = new Set([
|
||||
"--value", "--section", "--to", "--status", "--row-file", "--category", "--branch",
|
||||
"--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr",
|
||||
"--search", "--title-prefix", "--inactive-hours", "--comment", "--comment-file", "--description",
|
||||
"--attachment", "--output", "--file", "--hunk",
|
||||
"--attachment", "--output", "--file", "--hunk", "--sync-node",
|
||||
]);
|
||||
|
||||
export const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--body-stdin", "--body-patch-stdin", "--comment-stdin", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--private", "--public", "--auto-init"]);
|
||||
export const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--body-stdin", "--body-patch-stdin", "--comment-stdin", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--keep-branch", "--skip-local-closeout", "--private", "--public", "--auto-init"]);
|
||||
|
||||
export const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS;
|
||||
|
||||
@@ -460,6 +460,8 @@ export interface GitHubOptions {
|
||||
boardRowUpsertValues: BoardRowUpsertValues;
|
||||
mergeMethod: PullRequestMergeMethod;
|
||||
deleteBranch: boolean;
|
||||
localCloseout: boolean;
|
||||
syncNodes: string[];
|
||||
attachmentSelector?: string;
|
||||
outputPath?: string;
|
||||
filePath?: string;
|
||||
|
||||
Reference in New Issue
Block a user