diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts index 1f61f41a1..f092bc7b1 100644 --- a/apps/fabro-web/app/lib/run-actions.test.ts +++ b/apps/fabro-web/app/lib/run-actions.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { AxiosAdapter } from "axios"; -import type { Run, RunStatus } from "@qltysh/fabro-api-client"; +import type { BatchRunLifecycleResponse, Run, RunStatus } from "@qltysh/fabro-api-client"; import { archiveRun, + archiveRuns, canArchive, canApprove, canCancel, @@ -14,6 +15,7 @@ import { mapError, retryRun, unarchiveRun, + unarchiveRuns, } from "./run-actions"; import { generatedAxios } from "./api-client"; @@ -23,6 +25,12 @@ type StubResponseInit = { statusText?: string; }; +type CapturedRequest = { + url?: string; + method?: string; + data?: unknown; +}; + const originalAdapter = generatedAxios.defaults.adapter; function makeRun(status: RunStatus, archived = false): Run { @@ -65,8 +73,14 @@ function makeRun(status: RunStatus, archived = false): Run { }; } -function stubGeneratedAxiosOnce(init: StubResponseInit) { +function stubGeneratedAxiosOnce(init: StubResponseInit): { requests: CapturedRequest[] } { + const requests: CapturedRequest[] = []; generatedAxios.defaults.adapter = (async (config) => { + requests.push({ + url: config.url, + method: config.method, + data: config.data, + }); if (init.status >= 400) { throw { isAxiosError: true, @@ -87,6 +101,25 @@ function stubGeneratedAxiosOnce(init: StubResponseInit) { config, }; }) as AxiosAdapter; + return { requests }; +} + +function batchResponse( + results: BatchRunLifecycleResponse["results"], +): BatchRunLifecycleResponse { + const succeeded = results.filter((result) => result.ok).length; + return { + results, + summary: { + requested: results.length, + succeeded, + failed: results.length - succeeded, + }, + }; +} + +function requestJsonBody(request: CapturedRequest): unknown { + return typeof request.data === "string" ? JSON.parse(request.data) : request.data; } async function expectLifecycleError( @@ -141,6 +174,76 @@ describe("run lifecycle actions", () => { expect(result.lifecycle.archived).toBe(false); }); + test("archiveRuns sends one batch request and parses results", async () => { + const stub = stubGeneratedAxiosOnce({ + status: 200, + body: batchResponse([ + { + run_id: "run-1", + ok: true, + outcome: "archived", + run: { ...makeRun({ kind: "succeeded", reason: "completed" }, true), id: "run-1" }, + }, + { + run_id: "run-2", + ok: true, + outcome: "already_archived", + run: { ...makeRun({ kind: "succeeded", reason: "completed" }, true), id: "run-2" }, + }, + ]), + }); + + const result = await archiveRuns(["run-1", "run-2"]); + + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]?.method?.toUpperCase()).toBe("POST"); + expect(stub.requests[0]?.url).toBe("/api/v1/runs/archive"); + expect(requestJsonBody(stub.requests[0]!)).toEqual({ run_ids: ["run-1", "run-2"] }); + expect(result.summary).toEqual({ requested: 2, succeeded: 2, failed: 0 }); + expect(result.results.map((entry) => entry.outcome)).toEqual(["archived", "already_archived"]); + }); + + test("unarchiveRuns resolves mixed per-item results without throwing", async () => { + stubGeneratedAxiosOnce({ + status: 200, + body: batchResponse([ + { + run_id: "run-1", + ok: true, + outcome: "unarchived", + run: { ...makeRun({ kind: "succeeded", reason: "completed" }), id: "run-1" }, + }, + { + run_id: "run-missing", + ok: false, + outcome: "not_found", + error: { status: "404", title: "Not Found", detail: "Run not found." }, + }, + ]), + }); + + const result = await unarchiveRuns(["run-1", "run-missing"]); + + expect(result.summary).toEqual({ requested: 2, succeeded: 1, failed: 1 }); + expect(result.results[1]?.ok).toBe(false); + expect(result.results[1]?.error?.status).toBe("404"); + }); + + test("batch lifecycle helpers preserve request-level error envelopes", async () => { + stubGeneratedAxiosOnce({ + status: 400, + body: { + errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }], + }, + }); + + const error = await expectLifecycleError(archiveRuns([])); + expect(error).toEqual({ + status: 400, + errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }], + }); + }); + test("retryRun parses a 201 response", async () => { stubGeneratedAxiosOnce({ status: 201, diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts index a785feafa..819ec29de 100644 --- a/apps/fabro-web/app/lib/run-actions.ts +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -1,4 +1,9 @@ -import type { ErrorResponseEntry, Run } from "@qltysh/fabro-api-client"; +import type { + BatchRunLifecycleRequest, + BatchRunLifecycleResponse, + ErrorResponseEntry, + Run, +} from "@qltysh/fabro-api-client"; import { ApiError, @@ -58,6 +63,20 @@ export async function unarchiveRun(id: string, request?: Request): Promise return runLifecycleAction(id, "unarchive", request); } +export async function archiveRuns( + runIds: string[], + request?: Request, +): Promise { + return batchRunLifecycleAction(runIds, "archive", request); +} + +export async function unarchiveRuns( + runIds: string[], + request?: Request, +): Promise { + return batchRunLifecycleAction(runIds, "unarchive", request); +} + export async function retryRun(id: string, request?: Request): Promise { return runLifecycleAction(id, "retry", request); } @@ -182,6 +201,27 @@ async function runLifecycleAction( } } +async function batchRunLifecycleAction( + runIds: string[], + action: "archive" | "unarchive", + request?: Request, +): Promise { + try { + // openapi-generator's TypeScript client represents `uniqueItems` arrays as + // Set, but the HTTP wire contract is still a JSON array. Keep an array + // here so Axios serializes the request body correctly. + const body = { run_ids: runIds } as unknown as BatchRunLifecycleRequest; + switch (action) { + case "archive": + return await apiData(() => runsApi.batchArchiveRuns(body, requestSignalOptions(request))); + case "unarchive": + return await apiData(() => runsApi.batchUnarchiveRuns(body, requestSignalOptions(request))); + } + } catch (error) { + throw lifecycleActionErrorFromError(error); + } +} + function lifecycleActionErrorFromError(error: unknown): LifecycleActionError { if (!(error instanceof ApiError)) throw error; return { diff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx index c56e56e1e..94d8f5cff 100644 --- a/apps/fabro-web/app/routes/runs.test.tsx +++ b/apps/fabro-web/app/routes/runs.test.tsx @@ -9,6 +9,7 @@ import { RUNS_PREFERENCES_STORAGE_KEY, runsQuickStartCommands, shouldRefreshBoardForEvent, + summarizeBatchLifecycleAction, } from "./runs"; function boardRun(id: string, column: BoardColumn, questionText?: string): Run { @@ -173,6 +174,30 @@ describe("runs route board mapping", () => { "fabro run hello", ]); }); + + test("summarizes successful batch archive and unarchive actions", () => { + expect( + summarizeBatchLifecycleAction("Archive", { requested: 2, succeeded: 2, failed: 0 }), + ).toEqual({ message: "Archived 2 runs." }); + expect( + summarizeBatchLifecycleAction("Unarchive", { requested: 1, succeeded: 1, failed: 0 }), + ).toEqual({ message: "Unarchived 1 run." }); + }); + + test("summarizes partial and failed batch lifecycle actions", () => { + expect( + summarizeBatchLifecycleAction("Archive", { requested: 3, succeeded: 2, failed: 1 }), + ).toEqual({ + message: "Archived 2 of 3 runs. 1 failed.", + tone: "error", + }); + expect( + summarizeBatchLifecycleAction("Unarchive", { requested: 2, succeeded: 0, failed: 2 }), + ).toEqual({ + message: "Couldn't unarchive 2 runs. Try again.", + tone: "error", + }); + }); }); describe("runs route workspace preferences", () => { diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 2388a8859..5e26b7c96 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -27,12 +27,15 @@ import { formatRelativeTime } from "../lib/format"; import { EmptyState } from "../components/state"; import { InlineMarkdown } from "../components/inline-markdown"; import { PullRequestChip } from "../components/pull-request-chip"; +import { plural } from "../components/settings-panel"; import { useToast } from "../components/toast"; import { mutateRunListCaches } from "../lib/board-cache"; import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events"; import { useAllRuns, useAuthConfig, useRunsPage, useSystemInfo } from "../lib/queries"; -import { archiveRun, canArchive, canUnarchive, unarchiveRun } from "../lib/run-actions"; +import { archiveRuns, canArchive, canUnarchive, unarchiveRuns } from "../lib/run-actions"; import type { + BatchRunLifecycleResponse, + BatchRunLifecycleSummary, BoardColumn, ListRunsDirectionEnum, ListRunsSortEnum, @@ -65,6 +68,33 @@ const columnStyles: Record = { const defaultColumnStyle: ColumnStyle = { actions: [] }; const defaultColumnColors = { label: "", dot: "bg-fg-muted", text: "text-fg-muted" }; +type BatchLifecycleLabel = "Archive" | "Unarchive"; + +interface BatchLifecycleToast { + message: string; + tone?: "error"; +} + +export function summarizeBatchLifecycleAction( + label: BatchLifecycleLabel, + summary: BatchRunLifecycleSummary, +): BatchLifecycleToast { + const { requested, succeeded, failed } = summary; + if (failed === 0) { + return { message: `${label}d ${succeeded} ${plural(succeeded, "run", "runs")}.` }; + } + if (succeeded === 0) { + return { + message: `Couldn't ${label.toLowerCase()} ${requested} ${plural(requested, "run", "runs")}. Try again.`, + tone: "error", + }; + } + return { + message: `${label}d ${succeeded} of ${requested} ${plural(requested, "run", "runs")}. ${failed} failed.`, + tone: "error", + }; +} + interface BoardRunsResponse { data: Run[]; } @@ -463,25 +493,16 @@ function ColumnActionsMenu({ column }: { column: Column }) { setPending(true); const total = archivable.length; try { - const results = await Promise.allSettled( - archivable.map((item) => archiveRun(item.id)), + const response = await archiveRuns(archivable.map((item) => item.id)); + push(summarizeBatchLifecycleAction("Archive", response.summary)); + } catch { + push( + summarizeBatchLifecycleAction("Archive", { + requested: total, + succeeded: 0, + failed: total, + }), ); - const succeeded = results.filter((r) => r.status === "fulfilled").length; - const failed = total - succeeded; - const runWord = (n: number) => (n === 1 ? "run" : "runs"); - if (failed === 0) { - push({ message: `Archived ${total} ${runWord(total)}.` }); - } else if (succeeded === 0) { - push({ - message: `Couldn't archive ${total} ${runWord(total)}. Try again.`, - tone: "error", - }); - } else { - push({ - message: `Archived ${succeeded} of ${total} runs. ${failed} failed.`, - tone: "error", - }); - } } finally { setPending(false); mutateRunListCaches(mutate); @@ -1408,37 +1429,34 @@ function BulkActionToolbar({ if (count === 0) return null; - const runWord = (n: number) => (n === 1 ? "run" : "runs"); - async function runBulk( label: "Archive" | "Unarchive", eligible: RunWithStatus[], - action: (id: string) => Promise, + action: (ids: string[]) => Promise, ) { if (pending) return; if (eligible.length === 0) { - push({ message: `No selected ${runWord(count)} can be ${label.toLowerCase()}d.`, tone: "error" }); + push({ + message: `No selected ${plural(count, "run", "runs")} can be ${label.toLowerCase()}d.`, + tone: "error", + }); return; } setPending(true); try { - const results = await Promise.allSettled(eligible.map((r) => action(r.id))); - const succeeded = results.filter((r) => r.status === "fulfilled").length; - const failed = eligible.length - succeeded; - if (failed === 0) { - push({ message: `${label}d ${succeeded} ${runWord(succeeded)}.` }); + const response = await action(eligible.map((r) => r.id)); + push(summarizeBatchLifecycleAction(label, response.summary)); + if (response.summary.failed === 0) { onClear(); - } else if (succeeded === 0) { - push({ - message: `Couldn't ${label.toLowerCase()} ${eligible.length} ${runWord(eligible.length)}. Try again.`, - tone: "error", - }); - } else { - push({ - message: `${label}d ${succeeded} of ${eligible.length} ${runWord(eligible.length)}. ${failed} failed.`, - tone: "error", - }); } + } catch { + push( + summarizeBatchLifecycleAction(label, { + requested: eligible.length, + succeeded: 0, + failed: eligible.length, + }), + ); } finally { setPending(false); mutateRunListCaches(mutate); @@ -1453,20 +1471,20 @@ function BulkActionToolbar({ >
- {count} {runWord(count)} selected + {count} {plural(count, "run", "runs")} selected