177 lines
7.4 KiB
TypeScript
177 lines
7.4 KiB
TypeScript
// Responsibility: fail-closed delivery authority and safe navigation for the retired branch-follower surface.
|
|
import {
|
|
pacAutomaticDeliveryFix,
|
|
pacNodeReadOnlyNext,
|
|
resolveCicdDeliveryAuthority,
|
|
type CicdDeliveryAuthority,
|
|
} from "./cicd-delivery-authority";
|
|
import type { BranchFollowerRegistry, FollowerSpec, ParsedOptions } from "./cicd-types";
|
|
|
|
export type BranchFollowerDeliveryAuthority =
|
|
| {
|
|
readonly kind: "retired-readonly";
|
|
readonly mutationHintsAllowed: false;
|
|
readonly followers: readonly string[];
|
|
readonly pacAuthorities: readonly Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }>[];
|
|
readonly reason: "all-selected-followers-retired-to-pac";
|
|
}
|
|
| {
|
|
readonly kind: "legacy-manual";
|
|
readonly mutationHintsAllowed: true;
|
|
readonly followers: readonly string[];
|
|
readonly reason: "all-selected-followers-explicit-legacy-manual";
|
|
}
|
|
| {
|
|
readonly kind: "unknown";
|
|
readonly mutationHintsAllowed: false;
|
|
readonly followers: readonly string[];
|
|
readonly reason: "authority-not-declared" | "authority-mixed" | "retired-pac-identity-invalid" | "legacy-conflicts-with-pac";
|
|
readonly details: readonly Record<string, unknown>[];
|
|
};
|
|
|
|
export function branchFollowerDeliveryAuthority(
|
|
registry: BranchFollowerRegistry,
|
|
options: ParsedOptions,
|
|
): BranchFollowerDeliveryAuthority {
|
|
const followers = selectedFollowers(registry, options);
|
|
const rows = followers.map(resolveFollowerAuthority);
|
|
if (rows.every((row) => row.kind === "retired-readonly")) {
|
|
return {
|
|
kind: "retired-readonly",
|
|
mutationHintsAllowed: false,
|
|
followers: followers.map((follower) => follower.id),
|
|
pacAuthorities: rows.map((row) => row.pacAuthority).filter((item): item is Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }> => item !== null),
|
|
reason: "all-selected-followers-retired-to-pac",
|
|
};
|
|
}
|
|
if (rows.every((row) => row.kind === "legacy-manual")) {
|
|
return {
|
|
kind: "legacy-manual",
|
|
mutationHintsAllowed: true,
|
|
followers: followers.map((follower) => follower.id),
|
|
reason: "all-selected-followers-explicit-legacy-manual",
|
|
};
|
|
}
|
|
const reason = rows.some((row) => row.reason === "retired-pac-identity-invalid")
|
|
? "retired-pac-identity-invalid"
|
|
: rows.some((row) => row.reason === "legacy-conflicts-with-pac")
|
|
? "legacy-conflicts-with-pac"
|
|
: rows.some((row) => row.kind === "unknown")
|
|
? "authority-not-declared"
|
|
: "authority-mixed";
|
|
return {
|
|
kind: "unknown",
|
|
mutationHintsAllowed: false,
|
|
followers: followers.map((follower) => follower.id),
|
|
reason,
|
|
details: rows.map((row) => ({ follower: row.follower, kind: row.kind, reason: row.reason, authority: row.pacAuthority })),
|
|
};
|
|
}
|
|
|
|
export function branchFollowerMutationGuard(
|
|
registry: BranchFollowerRegistry,
|
|
options: ParsedOptions,
|
|
): Record<string, unknown> | null {
|
|
const deliveryMutation = options.action === "apply"
|
|
|| options.action === "run-once"
|
|
|| options.action === "job"
|
|
|| options.action === "gate"
|
|
|| (options.action === "debug-step" && options.debugStep === "state-write");
|
|
const retiredCleanup = options.action === "cleanup-state";
|
|
if (!deliveryMutation && !retiredCleanup) return null;
|
|
|
|
const authority = branchFollowerDeliveryAuthority(registry, options);
|
|
if (deliveryMutation && authority.kind === "legacy-manual" && options.scope === "legacy-cicd") return null;
|
|
if (retiredCleanup && authority.kind === "retired-readonly" && options.scope === "retired-maintenance") return null;
|
|
|
|
return {
|
|
ok: false,
|
|
action: options.action,
|
|
mutation: false,
|
|
mode: authority.kind === "retired-readonly"
|
|
? "retired-branch-follower-mutation-blocked"
|
|
: authority.kind === "legacy-manual"
|
|
? "legacy-branch-follower-scope-required"
|
|
: "branch-follower-delivery-authority-unknown",
|
|
reason: retiredCleanup
|
|
? "退役状态清理只允许通过 retired-maintenance scoped command;默认入口不修改控制器状态。"
|
|
: "该 branch-follower 已退役或 authority 无法唯一确认;CLI 不执行交付写操作。",
|
|
deliveryAuthority: authority,
|
|
next: branchFollowerReadOnlyNext(registry, options, authority),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function branchFollowerReadOnlyNext(
|
|
registry: BranchFollowerRegistry,
|
|
options: ParsedOptions,
|
|
authority = branchFollowerDeliveryAuthority(registry, options),
|
|
): Record<string, unknown> {
|
|
const suffix = options.followerId === null ? "" : ` --follower ${options.followerId}`;
|
|
const nodes = authority.kind === "retired-readonly"
|
|
? Array.from(new Set(authority.pacAuthorities.map((item) => item.consumer.node)))
|
|
: [];
|
|
const followerNext = {
|
|
status: `bun scripts/cli.ts cicd branch-follower status${suffix}`,
|
|
events: `bun scripts/cli.ts cicd branch-follower events${suffix}`,
|
|
logs: `bun scripts/cli.ts cicd branch-follower logs${suffix}`,
|
|
fixAutomaticDelivery: authority.kind === "unknown"
|
|
? pacAutomaticDeliveryFix(authorityFromUnknown(authority))
|
|
: { reference: "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界", mutation: false },
|
|
valuesPrinted: false,
|
|
};
|
|
if (nodes.length !== 1) return followerNext;
|
|
const pacNext = pacNodeReadOnlyNext(nodes[0] as string);
|
|
return {
|
|
...followerNext,
|
|
pacStatus: pacNext.status,
|
|
pacHistory: pacNext.history,
|
|
};
|
|
}
|
|
|
|
function selectedFollowers(registry: BranchFollowerRegistry, options: ParsedOptions): FollowerSpec[] {
|
|
const selected = options.followerId === null
|
|
? registry.followers
|
|
: registry.followers.filter((follower) => follower.id === options.followerId);
|
|
if (selected.length === 0) throw new Error(`unknown follower ${options.followerId ?? "-"}`);
|
|
return selected;
|
|
}
|
|
|
|
function resolveFollowerAuthority(follower: FollowerSpec): {
|
|
follower: string;
|
|
kind: "retired-readonly" | "legacy-manual" | "unknown";
|
|
reason: string;
|
|
pacAuthority: Extract<CicdDeliveryAuthority, { kind: "pac-pr-merge" }> | null;
|
|
} {
|
|
const pacAuthority = resolveCicdDeliveryAuthority({
|
|
node: follower.target.node,
|
|
lane: follower.target.lane,
|
|
sourceRepository: follower.source.repository,
|
|
sourceBranch: follower.source.branch,
|
|
});
|
|
if (follower.deliveryAuthority === "retired-readonly") {
|
|
return pacAuthority.kind === "pac-pr-merge"
|
|
? { follower: follower.id, kind: "retired-readonly", reason: "exact-pac-consumer", pacAuthority }
|
|
: { follower: follower.id, kind: "unknown", reason: "retired-pac-identity-invalid", pacAuthority: null };
|
|
}
|
|
if (follower.deliveryAuthority === "legacy-manual") {
|
|
return pacAuthority.kind === "pac-pr-merge"
|
|
? { follower: follower.id, kind: "unknown", reason: "legacy-conflicts-with-pac", pacAuthority }
|
|
: { follower: follower.id, kind: "legacy-manual", reason: "explicit-legacy-manual", pacAuthority: null };
|
|
}
|
|
return { follower: follower.id, kind: "unknown", reason: "authority-not-declared", pacAuthority: null };
|
|
}
|
|
|
|
function authorityFromUnknown(authority: Extract<BranchFollowerDeliveryAuthority, { kind: "unknown" }>): Extract<CicdDeliveryAuthority, { kind: "unknown" }> {
|
|
return {
|
|
kind: "unknown",
|
|
migrated: false,
|
|
mutationHintsAllowed: false,
|
|
consumer: null,
|
|
reason: "authority-identity-mismatch",
|
|
detail: authority.reason,
|
|
configRefs: ["config/cicd-branch-followers.yaml", "config/platform-infra/pipelines-as-code.yaml"],
|
|
valuesPrinted: false,
|
|
};
|
|
}
|