From 2663003c4a5989401d4d4159bc59e9c7815d2f21 Mon Sep 17 00:00:00 2001 From: Rishit Sharma Date: Tue, 11 Aug 2026 12:15:26 +0530 Subject: [PATCH 1/3] refactor(cli): extract the shared deployment-list fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deployment list` built its own query string inline: project scope from --project or the linked project, optional environment filter, and perPage clamped to the API's 100-row ceiling. Any other subcommand needing the same page had to copy all of it. Pull it into `fetchDeployments()` and point `list` at it. No behaviour change: same query parameters, same clamp, same `data ?? []` fallback. `deployment list` had no test coverage at all, so add two before touching it — one asserting the query it builds for --project/--env/--limit and the rendered row, one pinning the 100-row clamp. Both pass before and after the extraction. --- apps/cli/src/commands/deployment.ts | 30 +++++++++++------ apps/cli/test/e2e/deployment.test.ts | 48 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/deployment.ts b/apps/cli/src/commands/deployment.ts index 08f9b801c..15ddc413f 100644 --- a/apps/cli/src/commands/deployment.ts +++ b/apps/cli/src/commands/deployment.ts @@ -46,6 +46,25 @@ function shortSha(v: unknown): string { return typeof v === "string" ? v.slice(0, 7) : ""; } +/** 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 +80,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, diff --git a/apps/cli/test/e2e/deployment.test.ts b/apps/cli/test/e2e/deployment.test.ts index 33ddbf88c..ab5482abe 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(() => ({ From 911f9875236d4012f8b4033db14e373b5488e30f Mon Sep 17 00:00:00 2001 From: Rishit Sharma Date: Tue, 11 Aug 2026 12:15:26 +0530 Subject: [PATCH 2/3] feat(cli): add the pure binary-search core for deployment bisect Narrowing a good/bad range is the only real logic in a bisect: pick the midpoint, then move the good boundary up, the bad boundary down, or drop a candidate that cannot be judged. Keep it in its own module with no I/O so it is unit-testable without mocking prompts. `bisectMidpoint` returns -1 once only the boundary pair is left, which is also the termination signal; for a range of three or more it never lands on a boundary, so an already-known deployment is never re-asked. A skipped candidate is never labelled good or bad, so the final bracket can be wider than the minimal transition pair. That is intended: the invariant the caller reports on is that the lower bound is genuinely good and the upper bound genuinely bad, not that they are adjacent. Covered by a test that skips a midpoint and asserts the surviving bracket still straddles the real transition. --- apps/cli/src/lib/bisect.ts | 30 +++++++++++++ apps/cli/test/unit/bisect.test.ts | 73 +++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 apps/cli/src/lib/bisect.ts create mode 100644 apps/cli/test/unit/bisect.test.ts 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/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); + }); +}); From 9661debd404c5a820a3394df76b2d99f9acba550 Mon Sep 17 00:00:00 2001 From: Rishit Sharma Date: Tue, 11 Aug 2026 12:15:26 +0530 Subject: [PATCH 3/3] feat(cli): add `openship deployment bisect` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deployment list` shows the history and `deployment rollback` reverts to a chosen deployment, but nothing narrows "one of these 40 deployments broke it" to a single culprit — that meant walking the history one rollback at a time. Add a binary search over the same history: fetch one page via the shared `fetchDeployments`, keep the deployments that actually serve something, sort oldest-first, then ask good/bad/skip for each midpoint until only the boundary pair remains. Reports the first bad deployment and the last known-good one, and offers a rollback to the good one. Roughly log2(n) checks instead of n. No new API route and no new dependency: it reads GET /deployments and writes POST /deployments/:id/rollback, both already used by `list` and `rollback`, and reuses the existing run()/report()/confirm()/shortSha() helpers plus the @clack/prompts and open packages the CLI already ships. Only `ready` and `partial_failure` are treated as testable — queued/building/ deploying have not finished and failed/cancelled/rejected have nothing to visit. Note the API persists `ready`; `success` is a dashboard-side display mapping and never appears in an API response. Refuses to run without a TTY or under --json, since neither can answer a prompt. Aborting (menu or Ctrl-C) and declining the final offer both leave the active deployment untouched. --- apps/cli/src/commands/deployment.ts | 130 ++++++++++++++++++++++- apps/cli/test/e2e/deployment.test.ts | 153 +++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/deployment.ts b/apps/cli/src/commands/deployment.ts index 15ddc413f..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,8 +50,8 @@ function shortSha(v: unknown): string { return typeof v === "string" ? v.slice(0, 7) : ""; } -/** Resolve the project scope, clamp paging to the API's 100-row ceiling, and - * return one page of deployments. */ +/** 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; @@ -162,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") @@ -286,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/test/e2e/deployment.test.ts b/apps/cli/test/e2e/deployment.test.ts index ab5482abe..6322b29d0 100644 --- a/apps/cli/test/e2e/deployment.test.ts +++ b/apps/cli/test/e2e/deployment.test.ts @@ -91,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", + ); + }); +});