diff --git a/apps/cli/src/commands/deployment.ts b/apps/cli/src/commands/deployment.ts index 08f9b801c..30481ae84 100644 --- a/apps/cli/src/commands/deployment.ts +++ b/apps/cli/src/commands/deployment.ts @@ -9,6 +9,7 @@ * usage GET /deployments/:id/usage (container usage) * redeploy POST /deployments/:id/redeploy { useExistingCommit? } * rollback POST /deployments/:id/rollback + * bisect — binary search over list + rollback, no new route * pin POST /deployments/:id/pin { pinned } * cancel POST /deployments/:id/cancel * restart POST /deployments/:id/restart @@ -20,9 +21,12 @@ */ import { Command } from "commander"; import { createInterface } from "node:readline"; +import { isCancel, select } from "@clack/prompts"; import { apiRequest, ApiError } from "../lib/api-client"; import { readProjectLink } from "../lib/project-link"; -import { isJsonMode, printJson, printTable, ok, err } from "../lib/output"; +// `info` is aliased — this module already binds that name to the `info` subcommand. +import { isJsonMode, printJson, printTable, ok, err, info as note } from "../lib/output"; +import { bisectDone, bisectMidpoint, bisectStep } from "../lib/bisect"; /** Wrap a subcommand action so ApiError surfaces cleanly and exits non-zero. */ function run(fn: (...args: A) => Promise) { @@ -46,6 +50,25 @@ function shortSha(v: unknown): string { return typeof v === "string" ? v.slice(0, 7) : ""; } +/** Shared by `list` and `bisect`: resolve the project scope, clamp paging to the + * API's 100-row ceiling, and return one page of deployments. */ +async function fetchDeployments(opts: { + project?: string; + env?: string; + limit?: string; +}): Promise[]> { + const projectId: string | undefined = opts.project || readProjectLink()?.projectId; + const params = new URLSearchParams(); + if (projectId) params.set("projectId", projectId); + if (opts.env) params.set("environment", opts.env); + params.set("perPage", String(Math.min(Number(opts.limit) || 50, 100))); + const qs = params.toString(); + const res = await apiRequest<{ data?: Record[] }>( + `/deployments${qs ? `?${qs}` : ""}`, + ); + return res.data ?? []; +} + async function confirm(question: string): Promise { if (!process.stdin.isTTY) return true; const rl = createInterface({ input: process.stdin, output: process.stderr }); @@ -61,16 +84,7 @@ const list = new Command("list") .option("--limit ", "Max rows to fetch", "50") .action( run(async (opts) => { - const projectId: string | undefined = opts.project || readProjectLink()?.projectId; - const params = new URLSearchParams(); - if (projectId) params.set("projectId", projectId); - if (opts.env) params.set("environment", opts.env); - params.set("perPage", String(Math.min(Number(opts.limit) || 50, 100))); - const qs = params.toString(); - const res = await apiRequest<{ data?: Record[] }>( - `/deployments${qs ? `?${qs}` : ""}`, - ); - const rows = (res.data ?? []).map((d) => ({ + const rows = (await fetchDeployments(opts)).map((d) => ({ id: d.id, status: d.status, env: d.environment, @@ -152,6 +166,125 @@ const rollback = new Command("rollback") }), ); +interface BisectCandidate { + id: string; + branch: string; + commitSha: string | null; + version: number | null; + url: string | null; + createdAt: string; +} + +function describeCandidate(d: BisectCandidate): string { + return `${d.id} (${d.version ? `v${d.version}` : shortSha(d.commitSha)}, ${d.branch}, ${d.createdAt})`; +} + +const bisect = new Command("bisect") + .description("Binary-search deployment history to find the first bad deployment") + .option("--project ", "Scope to a project (defaults to the linked project)") + .option("--env ", "Filter by environment: production | preview") + .option("--limit ", "Max deployments to search through", "50") + .option("--good ", "Known-good deployment ID (defaults to the oldest fetched)") + .option("--bad ", "Known-bad deployment ID (defaults to the most recent)") + .action( + run(async (opts) => { + if (!process.stdin.isTTY || isJsonMode()) { + err("`deployment bisect` is interactive — it needs a TTY and cannot run under --json."); + process.exit(1); + } + + // Only ready/partial_failure actually deployed something visitable — + // queued/building/failed/cancelled/rejected have nothing to look at. + const testable: BisectCandidate[] = (await fetchDeployments(opts)) + .filter((d) => d.status === "ready" || d.status === "partial_failure") + .map((d) => ({ + id: String(d.id), + branch: String(d.branch ?? ""), + commitSha: (d.commitSha as string | null) ?? null, + version: (d.version as number | null) ?? null, + url: (d.url as string | null) ?? null, + createdAt: String(d.createdAt ?? ""), + })) + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + + if (testable.length < 2) { + err( + `Need at least two testable deployments (status ready/partial_failure) to bisect; found ${testable.length}. Try --env or raise --limit.`, + ); + process.exit(1); + } + + const goodIndex = opts.good ? testable.findIndex((d) => d.id === opts.good) : 0; + const badIndex = opts.bad + ? testable.findIndex((d) => d.id === opts.bad) + : testable.length - 1; + + if (opts.good && goodIndex === -1) { + err( + `--good ${opts.good} not found among the last ${testable.length} testable deployments. Try --limit.`, + ); + process.exit(1); + } + if (opts.bad && badIndex === -1) { + err( + `--bad ${opts.bad} not found among the last ${testable.length} testable deployments. Try --limit.`, + ); + process.exit(1); + } + if (goodIndex >= badIndex) { + err( + "--good must be chronologically before --bad (need at least two testable deployments to bisect).", + ); + process.exit(1); + } + + let range = testable.slice(goodIndex, badIndex + 1); + note( + `Bisecting ${range.length} deployments between ${range[0].id} (good) and ${range[range.length - 1].id} (bad).\n`, + ); + + while (!bisectDone(range)) { + const mid = bisectMidpoint(range); + const candidate = range[mid]; + note(`Testing ${describeCandidate(candidate)}`); + if (candidate.url) { + note(` ${candidate.url}`); + try { + const { default: open } = await import("open"); + await open(candidate.url); + } catch { + /* best-effort only; the URL above still works */ + } + } + + const answer = await select({ + message: "Is this deployment good or bad?", + options: [ + { value: "good" as const, label: "Good", hint: "this deployment works" }, + { value: "bad" as const, label: "Bad", hint: "this deployment is broken" }, + { value: "skip" as const, label: "Skip", hint: "can't tell — untestable" }, + { value: "abort" as const, label: "Abort bisect" }, + ], + }); + if (isCancel(answer) || answer === "abort") { + err("Bisect aborted."); + process.exit(1); + } + range = bisectStep(range, mid, answer); + } + + const goodDep = range[0]; + const badDep = range[range.length - 1]; + ok(`\nFirst bad deployment: ${describeCandidate(badDep)}`); + note(`Last known good: ${describeCandidate(goodDep)}`); + + if (await confirm(`\nRoll back to ${goodDep.id} now?`)) { + const rbRes = await apiRequest(`/deployments/${goodDep.id}/rollback`, { method: "POST" }); + report(rbRes, `Rolled back to ${goodDep.id}`); + } + }), + ); + const pin = new Command("pin") .description("Pin (or unpin) a deployment's rollback artifact") .argument("", "Deployment ID") @@ -276,6 +409,7 @@ export const deploymentCommand = new Command("deployment") .addCommand(usage) .addCommand(redeploy) .addCommand(rollback) + .addCommand(bisect) .addCommand(pin) .addCommand(cancel) .addCommand(restart) diff --git a/apps/cli/src/lib/bisect.ts b/apps/cli/src/lib/bisect.ts new file mode 100644 index 000000000..59baf9a98 --- /dev/null +++ b/apps/cli/src/lib/bisect.ts @@ -0,0 +1,30 @@ +/** + * Pure binary-search core for `openship deployment bisect`. No I/O here — + * fetching the deployment list, prompting good/bad/skip, and the rollback + * call all live in commands/deployment.ts. Kept separate so the actual + * search logic is unit-testable without mocking @clack/prompts. + * + * Contract: `range` is chronological ascending, index 0 = known good, + * last index = known bad. + */ + +export type BisectAnswer = "good" | "bad" | "skip"; + +/** Index of the next candidate to test, or -1 once `range` can't be + * narrowed further (down to just the good/bad boundary pair). */ +export function bisectMidpoint(range: T[]): number { + return range.length > 2 ? Math.floor(range.length / 2) : -1; +} + +export function bisectDone(range: T[]): boolean { + return bisectMidpoint(range) === -1; +} + +/** Narrow `range` given the answer for `range[mid]`. "good" moves the good + * boundary up to the candidate; "bad" moves the bad boundary down to it; + * "skip" drops the candidate and keeps both boundaries as-is. */ +export function bisectStep(range: T[], mid: number, answer: BisectAnswer): T[] { + if (answer === "good") return range.slice(mid); + if (answer === "bad") return range.slice(0, mid + 1); + return [...range.slice(0, mid), ...range.slice(mid + 1)]; +} diff --git a/apps/cli/test/e2e/deployment.test.ts b/apps/cli/test/e2e/deployment.test.ts index 33ddbf88c..6322b29d0 100644 --- a/apps/cli/test/e2e/deployment.test.ts +++ b/apps/cli/test/e2e/deployment.test.ts @@ -11,6 +11,54 @@ import { runCommand, stubFetch, type FetchStub } from "../helpers/harness"; let fetchStub: FetchStub; afterEach(() => fetchStub?.restore()); +describe("openship deployment list", () => { + it("scopes the query by project and environment, and renders the rows", async () => { + fetchStub = stubFetch(() => ({ + json: { + data: [ + { + id: "dep1", + status: "ready", + environment: "production", + branch: "main", + commitSha: "abcdef1234567890", + isActive: true, + createdAt: "2024-01-01T00:00:00Z", + }, + ], + }, + })); + const { out, code } = await runCommand(deploymentCommand, [ + "list", + "--project", + "p1", + "--env", + "production", + "--limit", + "10", + ]); + expect(code).toBe(0); + expect(fetchStub.calls[0].url).toBe( + "http://api.test/api/deployments?projectId=p1&environment=production&perPage=10", + ); + expect(out).toContain("dep1"); + expect(out).toContain("abcdef1"); // commit column is the short sha + }); + + it("clamps --limit to the API's 100-row ceiling", async () => { + fetchStub = stubFetch(() => ({ json: { data: [] } })); + const { code } = await runCommand(deploymentCommand, [ + "list", + "--project", + "p1", + "--limit", + "500", + ]); + expect(code).toBe(0); + expect(fetchStub.calls[0].url).toContain("perPage=100"); + }); +}); + describe("openship deployment get", () => { it("GETs /deployments/:id and renders it", async () => { fetchStub = stubFetch(() => ({ @@ -43,3 +91,156 @@ describe("openship deployment rollback", () => { expect(fetchStub.calls[0].url).toBe("http://api.test/api/deployments/dep1/rollback"); }); }); + +// The interactive good/bad/skip loop and the final rollback prompt both read +// real stdin (@clack/prompts `select`, the local readline `confirm`) once a +// TTY is present — there's no mock seam for either in this harness (no other +// command's tests exercise that path either), so only the deterministic +// branches below are covered here. The binary-search math itself is fully +// unit-tested in test/unit/bisect.test.ts. +describe("openship deployment bisect", () => { + const realIsTTY = process.stdin.isTTY; + afterEach(() => { + (process.stdin as { isTTY?: boolean }).isTTY = realIsTTY; + }); + + it("refuses to run without a TTY — bisect needs a human to judge each candidate", async () => { + (process.stdin as { isTTY?: boolean }).isTTY = false; + const { err: errOut, code } = await runCommand(deploymentCommand, [ + "bisect", + "--project", + "p1", + ]); + expect(code).toBe(1); + expect(errOut).toContain("interactive"); + }); + + it("errors when nothing in range is testable", async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + fetchStub = stubFetch(() => ({ + json: { + data: [ + { id: "dep1", status: "building", branch: "main", createdAt: "2024-01-01T00:00:00Z" }, + ], + }, + })); + const { err: errOut, code } = await runCommand(deploymentCommand, [ + "bisect", + "--project", + "p1", + ]); + expect(code).toBe(1); + expect(errOut).toContain("Need at least two testable deployments"); + }); + + it("errors on a single testable deployment without blaming flags the user never passed", async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + fetchStub = stubFetch(() => ({ + json: { + data: [ + { id: "dep-only", status: "ready", branch: "main", createdAt: "2024-01-01T00:00:00Z" }, + ], + }, + })); + const { err: errOut, code } = await runCommand(deploymentCommand, [ + "bisect", + "--project", + "p1", + ]); + expect(code).toBe(1); + expect(errOut).toContain("Need at least two testable deployments"); + expect(errOut).not.toContain("--good"); + }); + + it("errors when --good isn't among the fetched deployments", async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + fetchStub = stubFetch(() => ({ + json: { + data: [ + { id: "dep-old", status: "ready", branch: "main", createdAt: "2024-01-01T00:00:00Z" }, + { id: "dep-new", status: "ready", branch: "main", createdAt: "2024-01-02T00:00:00Z" }, + ], + }, + })); + const { err: errOut, code } = await runCommand(deploymentCommand, [ + "bisect", + "--project", + "p1", + "--good", + "dep-missing", + ]); + expect(code).toBe(1); + expect(errOut).toContain("dep-missing"); + expect(errOut).toContain("not found"); + }); + + it("errors when --bad isn't among the fetched deployments", async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + fetchStub = stubFetch(() => ({ + json: { + data: [ + { id: "dep-old", status: "ready", branch: "main", createdAt: "2024-01-01T00:00:00Z" }, + { id: "dep-new", status: "ready", branch: "main", createdAt: "2024-01-02T00:00:00Z" }, + ], + }, + })); + const { err: errOut, code } = await runCommand(deploymentCommand, [ + "bisect", + "--project", + "p1", + "--bad", + "dep-missing", + ]); + expect(code).toBe(1); + expect(errOut).toContain("dep-missing"); + expect(errOut).toContain("not found"); + }); + + it("errors when --good is chronologically at or after --bad", async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + fetchStub = stubFetch(() => ({ + json: { + data: [ + { id: "dep-old", status: "ready", branch: "main", createdAt: "2024-01-01T00:00:00Z" }, + { id: "dep-new", status: "ready", branch: "main", createdAt: "2024-01-02T00:00:00Z" }, + ], + }, + })); + const { err: errOut, code } = await runCommand(deploymentCommand, [ + "bisect", + "--project", + "p1", + "--good", + "dep-new", + "--bad", + "dep-old", + ]); + expect(code).toBe(1); + expect(errOut).toContain("chronologically before"); + }); + + it("excludes queued/building/failed deployments from the testable set", async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + fetchStub = stubFetch(() => ({ + json: { + data: [ + { id: "dep-queued", status: "queued", branch: "main", createdAt: "2024-01-01T00:00:00Z" }, + { id: "dep-failed", status: "failed", branch: "main", createdAt: "2024-01-02T00:00:00Z" }, + ], + }, + })); + const { err: errOut, code } = await runCommand(deploymentCommand, [ + "bisect", + "--project", + "p1", + "--env", + "production", + ]); + expect(code).toBe(1); + expect(errOut).toContain("found 0"); + // bisect scopes its fetch exactly like `deployment list` does. + expect(fetchStub.calls[0].url).toBe( + "http://api.test/api/deployments?projectId=p1&environment=production&perPage=50", + ); + }); +}); diff --git a/apps/cli/test/unit/bisect.test.ts b/apps/cli/test/unit/bisect.test.ts new file mode 100644 index 000000000..42c9aeebf --- /dev/null +++ b/apps/cli/test/unit/bisect.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { bisectDone, bisectMidpoint, bisectStep } from "../../src/lib/bisect"; + +describe("bisectMidpoint / bisectDone", () => { + it("has no midpoint left once only the good/bad boundary pair remains", () => { + expect(bisectMidpoint([1, 2])).toBe(-1); + expect(bisectDone([1, 2])).toBe(true); + }); + + it("picks the middle index for a larger range", () => { + expect(bisectMidpoint([1, 2, 3])).toBe(1); + expect(bisectMidpoint([1, 2, 3, 4, 5])).toBe(2); + expect(bisectDone([1, 2, 3])).toBe(false); + }); +}); + +describe("bisectStep", () => { + const range = [1, 2, 3, 4, 5]; + + it("good moves the good boundary up to the candidate", () => { + expect(bisectStep(range, 2, "good")).toEqual([3, 4, 5]); + }); + + it("bad moves the bad boundary down to the candidate", () => { + expect(bisectStep(range, 2, "bad")).toEqual([1, 2, 3]); + }); + + it("skip drops the candidate and keeps both boundaries", () => { + expect(bisectStep(range, 2, "skip")).toEqual([1, 2, 4, 5]); + }); +}); + +describe("full binary search converges to the first bad element", () => { + it("finds the boundary between good and bad in a monotonic history", () => { + // element index 6 (value 6) is the first "bad" one — everything before is good. + const history = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + const firstBadIndex = 6; + let range = history; + let steps = 0; + while (!bisectDone(range)) { + const mid = bisectMidpoint(range); + range = bisectStep(range, mid, range[mid] >= firstBadIndex ? "bad" : "good"); + steps++; + expect(steps).toBeLessThan(10); // sanity: must converge, never loop forever + } + expect(range).toEqual([firstBadIndex - 1, firstBadIndex]); + }); + + it("skip drops the untestable candidate and still converges, without ever mislabeling the boundary", () => { + // A skipped candidate is never determined good or bad, so the final + // bracket may be wider than the minimal pair — but it must still be + // correct: range[0] genuinely good, range[last] genuinely bad. + const history = [0, 1, 2, 3, 4, 5]; + const firstBadIndex = 4; + let range = history; + let skippedOnce = false; + let steps = 0; + while (!bisectDone(range)) { + const mid = bisectMidpoint(range); + if (!skippedOnce) { + range = bisectStep(range, mid, "skip"); + skippedOnce = true; + } else { + range = bisectStep(range, mid, range[mid] >= firstBadIndex ? "bad" : "good"); + } + steps++; + expect(steps).toBeLessThan(10); + } + expect(range).toHaveLength(2); + expect(range[0]).toBeLessThan(firstBadIndex); + expect(range[1]).toBeGreaterThanOrEqual(firstBadIndex); + }); +});