// `archilyzer release cut | show` — cut a release from the command line, with // no editor running (release 10 slice P). The writer is // common/controller/cutRelease.ts, the same one the "Cut release" form and // POST /api/ops/cut-release call; this file is argument checking and one // printed line per changelog. // // archilyzer release show [editor|export] // archilyzer release cut [--commit] [--date YYYY-MM-DD] // // It works on the checkout it is run from (the monorepo root above the cwd; // EDITOR_CHANGELOG_FILE / EXPORT_CHANGELOG_FILE are honoured as everywhere). import type { CommandContext } from "./_cli"; import type { FlagValue } from "./_parseFlags"; import { compareVersions, suggestNextMinorVersion, suggestNextVersion } from "../lib/changelog"; import { cutReleases, dateProblem, describeRelease, isReleaseTarget, RELEASE_WORKSPACES, versionSpecProblem, type CutReleasesOutcome, type ReleaseSummary, type ReleaseTarget, type ReleaseWorkspace, } from "../controller/cutRelease"; type Out = { log: (s: string) => void; error: (s: string) => void }; export type CutArgs = { target: ReleaseTarget; version: string; commit: boolean; date?: string; }; /** `release cut`'s arguments, checked before anything is read — or why not. */ export function parseCutArgs( positionals: readonly string[], flags: Record, ): CutArgs | { error: string } { const [target, version] = positionals; if (!target) { return { error: "release cut: which changelog? editor, export or all" }; } if (!isReleaseTarget(target)) { return { error: `release cut: "${target}" is not editor, export or all` }; } if (!version) { return { error: "release cut: which version? X.Y.Z, next or next-minor" }; } const versionProblem = versionSpecProblem(version); if (versionProblem) return { error: `release cut: ${versionProblem}` }; const date = typeof flags.date === "string" ? flags.date.trim() : undefined; if (date !== undefined) { const problem = dateProblem(date); if (problem) return { error: `release cut: ${problem}` }; } return { target, version: version.trim(), commit: flags.commit === true, ...(date !== undefined ? { date } : {}), }; } /** `release show`'s changelogs: the one named, or both (`all` is both too). */ export function parseShowArgs( positionals: readonly string[], ): ReleaseWorkspace[] | { error: string } { const [which] = positionals; if (!which || which === "all") return [...RELEASE_WORKSPACES]; if (which === "editor" || which === "export") return [which]; return { error: `release show: "${which}" is not editor or export` }; } /** * One line per changelog: the heading now in the file and whether it was * committed; the refusal; and any changelog a failure stopped before — saying * whether `all` was refused before writing anything, or stopped half-way. */ export function formatCutLines(outcome: CutReleasesOutcome): { line: string; failed: boolean }[] { const lines = outcome.results.map((r) => r.ok ? { line: `${r.workspace}: ${r.heading} (${ r.committed ? r.commitSha ? `committed ${r.commitSha.slice(0, 8)}` : "committed" : "not committed" })`, failed: false, } : { line: `${r.workspace}: failed — ${r.error}`, failed: true }, ); for (const ws of outcome.notAttempted) { lines.push({ line: outcome.untouched ? `${ws}: not cut — all cuts both or neither, and nothing was written` : `${ws}: not cut — stopped at the failure above`, failed: true, }); } return lines; } function plural(n: number, word: string): string { return `${n} ${word}${n === 1 ? "" : "s"}`; } /** The step-0 check, one line: latest release, its date, what is pending. */ export function formatShowLine(s: ReleaseSummary): string { if (!s.ok) return `${s.workspace}: ${s.error}`; const latest = s.latest ? `latest ${s.latest.version} (${s.latest.date ?? "undated"})` : "no release yet"; const pending = !s.hasUnreleased ? "no [Unreleased] heading" : s.pending === 0 ? "nothing pending under [Unreleased]" : `${plural(s.pending, "bullet")} pending under [Unreleased]`; return `${s.workspace}: ${latest}; ${pending}; next ${s.next}, next-minor ${s.nextMinor}`; } /** * What `release cut all next` would cut: the keywords against the HIGHER of * the two latest headings (cutReleases' rule), or null unless both were read. */ export function formatAllLine(summaries: ReleaseSummary[]): string | null { if (summaries.length < 2 || summaries.some((s) => !s.ok)) return null; let highest: string | null = null; for (const s of summaries) { const v = s.ok ? (s.latest?.version ?? null) : null; if (compareVersions(v, highest) > 0) highest = v; } return `all: next ${suggestNextVersion(highest)}, next-minor ${suggestNextMinorVersion(highest)}`; } export async function cutMain(ctx: CommandContext, out: Out = console): Promise { const args = parseCutArgs(ctx.positionals, ctx.flags); if ("error" in args) { out.error(args.error); return 2; } const outcome = await cutReleases({ workspace: args.target, version: args.version, commit: args.commit, date: args.date, }); for (const { line, failed } of formatCutLines(outcome)) { (failed ? out.error : out.log)(line); } return outcome.ok ? 0 : 1; } export async function showMain(ctx: CommandContext, out: Out = console): Promise { const which = parseShowArgs(ctx.positionals); if ("error" in which) { out.error(which.error); return 2; } const summaries: ReleaseSummary[] = []; for (const ws of which) summaries.push(await describeRelease(ws)); for (const s of summaries) (s.ok ? out.log : out.error)(formatShowLine(s)); const all = formatAllLine(summaries); if (all) out.log(all); return summaries.every((s) => s.ok) ? 0 : 1; }