diff --git a/package-lock.json b/package-lock.json index 29d78e7..90417b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@37signals/fizzy": "^0.2.1", "@anthropic-ai/sdk": "^0.39.0", "@clack/prompts": "^0.9.1", "@hono/node-server": "^1.13.8", @@ -33,6 +34,21 @@ "node": ">=22.0.0" } }, + "node_modules/@37signals/fizzy": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@37signals/fizzy/-/fizzy-0.2.1.tgz", + "integrity": "sha512-Hb78sxP8NO1wTeONZoUFsBuKIbgEYXtumJxPvmQUTzYElGNpOEVs7EzsHmZoe7AnfWTu+AsmYvFbgi2y+qEktw==", + "license": "MIT", + "dependencies": { + "openapi-fetch": "^0.17.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.39.0", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", @@ -1571,7 +1587,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -1825,6 +1840,21 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -1865,7 +1895,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2110,7 +2139,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2129,7 +2157,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2163,7 +2190,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2373,7 +2399,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -2401,7 +2426,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index cb426d6..e8d1df7 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "node": ">=22.0.0" }, "dependencies": { + "@37signals/fizzy": "^0.2.1", "@anthropic-ai/sdk": "^0.39.0", "@clack/prompts": "^0.9.1", "@hono/node-server": "^1.13.8", diff --git a/src/fizzy.ts b/src/fizzy.ts index 44ac4ea..15715a2 100644 --- a/src/fizzy.ts +++ b/src/fizzy.ts @@ -1,4 +1,8 @@ import { createHmac, timingSafeEqual } from "node:crypto" +import { + createFizzyClient as createOfficialFizzyClient, + type FizzyClient as OfficialFizzyClient, +} from "@37signals/fizzy" import type { Config } from "./config.js" // ── Domain types ── @@ -108,148 +112,130 @@ export interface AgentRun { abort_controller: AbortController } -// ── Fizzy REST client ── +// ── Fizzy SDK-backed client ── export class FizzyClient { private baseUrl: string private account: string private token: string + private rootClient: OfficialFizzyClient + private scopedClient: OfficialFizzyClient | null = null constructor(config: Config) { this.baseUrl = config.fizzy.api_url.replace(/\/$/, "") this.account = config.fizzy.account this.token = config.fizzy.token - } - private url(path: string): string { - return `${this.baseUrl}/${this.account}${path}` + this.rootClient = createOfficialFizzyClient({ + accessToken: this.token, + baseUrl: this.baseUrl, + }) } - private async request(path: string, options: RequestInit = {}): Promise { - const url = this.url(path) - const response = await fetch(url, { - ...options, - headers: { - "Authorization": `Bearer ${this.token}`, - "Accept": "application/json", - "Content-Type": "application/json", - ...options.headers, - }, + private accountClient(): OfficialFizzyClient { + if (!this.account) { + throw new Error("Fizzy account is required for account-scoped API calls") + } + + this.scopedClient ??= createOfficialFizzyClient({ + accessToken: this.token, + baseUrl: `${this.baseUrl}/${encodeURIComponent(this.account)}`, }) - if (!response.ok) { - const text = await response.text().catch(() => "") - throw new Error(`Fizzy API ${response.status}: ${response.statusText} — ${text}`) + return this.scopedClient + } + + private async sdkRequest(operation: () => Promise): Promise { + try { + return await operation() + } catch (error) { + if (error instanceof Error) { + throw new Error(`Fizzy API: ${error.message}`) + } + throw error } + } - const text = await response.text() - if (!text) return undefined as T + private async sdkList(operation: () => Promise>): Promise { + const result = await this.sdkRequest(operation) + return [...result] + } - return JSON.parse(text) as T + private async sdkMutation(operation: () => Promise): Promise { + await this.sdkRequest(operation) } - private async paginatedRequest(path: string): Promise { - const results: T[] = [] - let url: string | null = this.url(path) - - while (url) { - const response = await fetch(url, { - headers: { - "Authorization": `Bearer ${this.token}`, - "Accept": "application/json", - }, - }) - - if (!response.ok) { - const text = await response.text().catch(() => "") - throw new Error(`Fizzy API ${response.status}: ${response.statusText} — ${text}`) - } + private cardListOptions(params?: { board_ids?: string[] }): { boardIds?: string[] } | undefined { + if (!params?.board_ids?.length) return undefined + return { boardIds: params.board_ids } + } - const data = await response.json() as T[] - results.push(...data) + private async accountRequest(operation: (client: OfficialFizzyClient) => Promise): Promise { + return this.sdkRequest(() => operation(this.accountClient())) + } - // Parse Link header for next page - const link = response.headers.get("Link") || response.headers.get("link") - url = parseLinkNext(link) - } + private async list(operation: (client: OfficialFizzyClient) => Promise>): Promise { + return this.sdkList(() => operation(this.accountClient())) + } + + private async mutate(operation: (client: OfficialFizzyClient) => Promise): Promise { + return this.sdkMutation(() => operation(this.accountClient())) + } - return results + private async root(operation: (client: OfficialFizzyClient) => Promise): Promise { + return this.sdkRequest(() => operation(this.rootClient)) } // ── Board operations ── async listBoards(): Promise { - return this.request("/boards") + return await this.list(client => client.boards.list()) as FizzyBoard[] } async getBoard(boardId: string): Promise { - return this.request(`/boards/${boardId}`) + return await this.accountRequest(client => client.boards.get(boardId)) as FizzyBoard } async listColumns(boardId: string): Promise { - return this.request(`/boards/${boardId}/columns`) + return await this.list(client => client.columns.list(boardId)) as FizzyColumn[] } // ── Card operations ── async listCards(params?: { board_ids?: string[] }): Promise { - let path = "/cards" - if (params?.board_ids?.length) { - const qs = params.board_ids.map(id => `board_ids[]=${encodeURIComponent(id)}`).join("&") - path += `?${qs}` - } - return this.paginatedRequest(path) + return await this.list(client => client.cards.list(this.cardListOptions(params))) as FizzyCard[] } async getCard(cardNumber: number): Promise { - return this.request(`/cards/${cardNumber}`) + return await this.accountRequest(client => client.cards.get(cardNumber)) as FizzyCard } async listComments(cardNumber: number): Promise { - return this.paginatedRequest(`/cards/${cardNumber}/comments`) + return await this.list(client => client.comments.list(cardNumber)) as FizzyComment[] } // ── Card mutations ── async closeCard(cardNumber: number): Promise { - await this.request(`/cards/${cardNumber}/closure`, { method: "POST" }) + await this.mutate(client => client.cards.close(cardNumber)) } async triageCard(cardNumber: number, columnId: string): Promise { - await this.request(`/cards/${cardNumber}/triage`, { - method: "POST", - body: JSON.stringify({ column_id: columnId }), - }) + await this.mutate(client => client.cards.triage(cardNumber, { columnId })) } async postComment(cardNumber: number, body: string): Promise { - await this.request(`/cards/${cardNumber}/comments`, { - method: "POST", - body: JSON.stringify({ comment: { body } }), - }) + await this.mutate(client => client.comments.create(cardNumber, { body })) } async toggleTag(cardNumber: number, tagTitle: string): Promise { - await this.request(`/cards/${cardNumber}/taggings`, { - method: "POST", - body: JSON.stringify({ tag_title: tagTitle }), - }) + await this.mutate(client => client.cards.tag(cardNumber, { tagTitle })) } // ── Identity ── async getIdentity(): Promise<{ accounts: Array<{ id: string; name: string; slug: string; user: FizzyUser }> }> { - const url = `${this.baseUrl}/my/identity` - const response = await fetch(url, { - headers: { - "Authorization": `Bearer ${this.token}`, - "Accept": "application/json", - }, - }) - if (!response.ok) { - throw new Error(`Fizzy API ${response.status}: ${response.statusText}`) - } - return response.json() as Promise<{ accounts: Array<{ id: string; name: string; slug: string; user: FizzyUser }> }> + return this.root(client => client.identity.me()) as Promise<{ accounts: Array<{ id: string; name: string; slug: string; user: FizzyUser }> }> } } @@ -273,14 +259,6 @@ export function isWebhookFresh(timestamp: string, toleranceSec: number = 300): b return Math.abs(now - eventTime) < toleranceSec * 1000 } -// ── Helpers ── - -function parseLinkNext(header: string | null): string | null { - if (!header) return null - const match = header.match(/<([^>]+)>;\s*rel="next"/) - return match ? match[1] : null -} - // ── Golden ticket parsing ── const BACKEND_TAGS = ["claude", "codex", "opencode", "anthropic", "openai"] as const diff --git a/test/fizzy.test.ts b/test/fizzy.test.ts index 0414efc..caa3883 100644 --- a/test/fizzy.test.ts +++ b/test/fizzy.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { createHmac } from "node:crypto" +import type { Mock } from "vitest" import { parseGoldenTicket, isGoldenTicket, @@ -7,7 +8,44 @@ import { isWebhookFresh, FizzyClient, } from "../src/fizzy.js" -import { makeCard, makeGoldenTicketCard, makeColumn, makeConfig } from "./fixtures.js" +import { makeCard, makeGoldenTicketCard, makeConfig } from "./fixtures.js" + +const sdkMock = vi.hoisted(() => { + const rootClient = { + identity: { + me: vi.fn(), + }, + } + const accountClient = { + boards: { + list: vi.fn(), + get: vi.fn(), + }, + columns: { + list: vi.fn(), + }, + cards: { + list: vi.fn(), + get: vi.fn(), + close: vi.fn(), + triage: vi.fn(), + tag: vi.fn(), + }, + comments: { + list: vi.fn(), + create: vi.fn(), + }, + } + const createFizzyClient = vi.fn(({ baseUrl }: { baseUrl: string }) => + baseUrl.endsWith("/123") || baseUrl.endsWith("/a") ? accountClient : rootClient, + ) + + return { rootClient, accountClient, createFizzyClient } +}) + +vi.mock("@37signals/fizzy", () => ({ + createFizzyClient: sdkMock.createFizzyClient, +})) describe("parseGoldenTicket", () => { it("returns null for a card without agent-instructions tag", () => { @@ -213,193 +251,129 @@ describe("FizzyClient", () => { let client: FizzyClient beforeEach(() => { + vi.clearAllMocks() + resetSdkDefaults() client = new FizzyClient(makeConfig()) }) - describe("url construction", () => { - it("constructs correct board list URL", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify([]), { status: 200 }), - ) - + describe("client construction", () => { + it("constructs root and account-scoped SDK clients", async () => { await client.listBoards() - expect(fetchSpy).toHaveBeenCalledWith( - "https://app.fizzy.do/123/boards", - expect.objectContaining({ - headers: expect.objectContaining({ - "Authorization": "Bearer fz_test_token", - }), - }), - ) - - fetchSpy.mockRestore() + expect(sdkMock.createFizzyClient).toHaveBeenNthCalledWith(1, { + accessToken: "fz_test_token", + baseUrl: "https://app.fizzy.do", + }) + expect(sdkMock.createFizzyClient).toHaveBeenNthCalledWith(2, { + accessToken: "fz_test_token", + baseUrl: "https://app.fizzy.do/123", + }) }) - it("strips trailing slash from api_url", () => { + it("strips trailing slash from api_url", async () => { const clientWithSlash = new FizzyClient(makeConfig({ fizzy: { token: "t", account: "a", api_url: "https://app.fizzy.do/" }, })) - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify([]), { status: 200 }), - ) - - clientWithSlash.listBoards() - - expect(fetchSpy).toHaveBeenCalledWith( - "https://app.fizzy.do/a/boards", - expect.any(Object), - ) + await clientWithSlash.listBoards() - fetchSpy.mockRestore() + expect(sdkMock.createFizzyClient).toHaveBeenLastCalledWith({ + accessToken: "t", + baseUrl: "https://app.fizzy.do/a", + }) }) }) describe("listCards", () => { - it("adds board_ids query params", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify([]), { - status: 200, - headers: {}, - }), - ) - + it("maps board_ids to SDK boardIds", async () => { await client.listCards({ board_ids: ["b1", "b2"] }) - expect(fetchSpy).toHaveBeenCalledWith( - "https://app.fizzy.do/123/cards?board_ids[]=b1&board_ids[]=b2", - expect.any(Object), - ) - - fetchSpy.mockRestore() + expect(sdkMock.accountClient.cards.list).toHaveBeenCalledWith({ + boardIds: ["b1", "b2"], + }) }) }) - describe("paginatedRequest", () => { - it("follows Link header for pagination", async () => { + describe("paginated lists", () => { + it("returns the SDK list result as a plain array", async () => { const page1 = [makeCard({ id: "card-1" })] const page2 = [makeCard({ id: "card-2" })] - - const fetchSpy = vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce( - new Response(JSON.stringify(page1), { - status: 200, - headers: { "Link": '; rel="next"' }, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(page2), { status: 200 }), - ) + mocked(sdkMock.accountClient.cards.list).mockResolvedValue([...page1, ...page2]) const results = await client.listCards() expect(results).toHaveLength(2) expect(results[0].id).toBe("card-1") expect(results[1].id).toBe("card-2") - expect(fetchSpy).toHaveBeenCalledTimes(2) - - fetchSpy.mockRestore() }) }) describe("error handling", () => { - it("throws on non-ok response", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response("Not found", { status: 404, statusText: "Not Found" }), - ) - - await expect(client.getCard(42)).rejects.toThrow("Fizzy API 404") + it("wraps SDK errors with a Fizzy API prefix", async () => { + mocked(sdkMock.accountClient.cards.get).mockRejectedValue(new Error("Not found")) - vi.restoreAllMocks() + await expect(client.getCard(42)).rejects.toThrow("Fizzy API: Not found") }) }) describe("mutations", () => { - it("closeCard sends POST to closure endpoint", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(null, { status: 204 }), - ) - + it("closeCard delegates to the SDK close method", async () => { await client.closeCard(42) - expect(fetchSpy).toHaveBeenCalledWith( - "https://app.fizzy.do/123/cards/42/closure", - expect.objectContaining({ method: "POST" }), - ) - - fetchSpy.mockRestore() + expect(sdkMock.accountClient.cards.close).toHaveBeenCalledWith(42) }) - it("triageCard sends column_id in body", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(null, { status: 204 }), - ) - + it("triageCard maps column_id to SDK columnId", async () => { await client.triageCard(42, "col-done") - expect(fetchSpy).toHaveBeenCalledWith( - "https://app.fizzy.do/123/cards/42/triage", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ column_id: "col-done" }), - }), - ) - - fetchSpy.mockRestore() + expect(sdkMock.accountClient.cards.triage).toHaveBeenCalledWith(42, { + columnId: "col-done", + }) }) it("postComment sends comment body", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(null, { status: 204 }), - ) - await client.postComment(42, "

Agent result

") - expect(fetchSpy).toHaveBeenCalledWith( - "https://app.fizzy.do/123/cards/42/comments", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ comment: { body: "

Agent result

" } }), - }), - ) - - fetchSpy.mockRestore() + expect(sdkMock.accountClient.comments.create).toHaveBeenCalledWith(42, { + body: "

Agent result

", + }) }) - it("toggleTag sends tag_title", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(null, { status: 204 }), - ) - + it("toggleTag maps tag_title to SDK tagTitle", async () => { await client.toggleTag(42, "pi-error") - expect(fetchSpy).toHaveBeenCalledWith( - "https://app.fizzy.do/123/cards/42/taggings", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ tag_title: "pi-error" }), - }), - ) - - fetchSpy.mockRestore() + expect(sdkMock.accountClient.cards.tag).toHaveBeenCalledWith(42, { + tagTitle: "pi-error", + }) }) }) describe("getIdentity", () => { - it("fetches /my/identity without account slug", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ accounts: [] }), { status: 200 }), - ) + it("uses the accountless root SDK client", async () => { + mocked(sdkMock.rootClient.identity.me).mockResolvedValue({ accounts: [] }) await client.getIdentity() - expect(fetchSpy).toHaveBeenCalledWith( - "https://app.fizzy.do/my/identity", - expect.any(Object), - ) - - fetchSpy.mockRestore() + expect(sdkMock.rootClient.identity.me).toHaveBeenCalled() + expect(sdkMock.accountClient.boards.list).not.toHaveBeenCalled() }) }) }) + +function resetSdkDefaults(): void { + mocked(sdkMock.accountClient.boards.list).mockResolvedValue([]) + mocked(sdkMock.accountClient.boards.get).mockResolvedValue({}) + mocked(sdkMock.accountClient.columns.list).mockResolvedValue([]) + mocked(sdkMock.accountClient.cards.list).mockResolvedValue([]) + mocked(sdkMock.accountClient.cards.get).mockResolvedValue(makeCard()) + mocked(sdkMock.accountClient.cards.close).mockResolvedValue(undefined) + mocked(sdkMock.accountClient.cards.triage).mockResolvedValue(undefined) + mocked(sdkMock.accountClient.cards.tag).mockResolvedValue(undefined) + mocked(sdkMock.accountClient.comments.list).mockResolvedValue([]) + mocked(sdkMock.accountClient.comments.create).mockResolvedValue({}) + mocked(sdkMock.rootClient.identity.me).mockResolvedValue({ accounts: [] }) +} + +function mocked unknown>(fn: T): Mock { + return fn as Mock +}