diff --git a/.env.example b/.env.example index de941ea..45d0f2d 100644 --- a/.env.example +++ b/.env.example @@ -22,9 +22,14 @@ RAG_CACHE_PATH= WIDGET_ALLOWED_ORIGINS= DASHBOARD_ORIGIN= -# Shortcut — if unset, uses mock client +# Shortcut — if SHORTCUT_API_TOKEN is unset, the api uses the mock client. +# Get a token at https://app.shortcut.com/settings/account/api-tokens +# SHORTCUT_WORKFLOW_STATE_ID is the numeric id of the workflow state new bug +# tickets should land in (often "Ready for Dev"). Find it via: +# GET https://api.app.shortcut.com/api/v3/workflows SHORTCUT_API_TOKEN= SHORTCUT_WORKSPACE= +SHORTCUT_WORKFLOW_STATE_ID= # GitHub — if unset OR ENABLE_PR_FLOW=false, uses mock PR client GITHUB_TOKEN= diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 96e6378..c18a6d6 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,6 +1,7 @@ import cors from "cors"; import express, { type Express, Router } from "express"; import type { ClaudeClient } from "./clients/claude.js"; +import type { ShortcutClient } from "./clients/shortcut.js"; import type { AppConfig } from "./config.js"; import type { FixtureLibrary } from "./fixtures/index.js"; import type { Logger } from "./logger.js"; @@ -24,6 +25,8 @@ export interface CreateAppDeps { retriever?: Retriever; /** Injectable Claude client (live or mock). When absent, pipeline stubs are used. */ claude?: ClaudeClient; + /** Injectable Shortcut client (live or mock). When absent, pipeline stub is used. */ + shortcut?: ShortcutClient; /** Fixture library — powers the mock client's ticket/PR metadata lookup. */ fixtures?: FixtureLibrary; /** Skips static /widget/* and /demo/* mounting — useful in tests. */ @@ -36,6 +39,7 @@ export function createApp({ sessions = new SessionStore(), retriever, claude, + shortcut, fixtures, skipWidgetStatic = false, }: CreateAppDeps): Express { @@ -62,7 +66,15 @@ export function createApp({ widgetRouter.use(widgetCors); widgetRouter.use(buildSessionInitRouter(sessions)); widgetRouter.use( - buildChatRouter({ config, logger, sessions, retriever, claude, fixtures }), + buildChatRouter({ + config, + logger, + sessions, + retriever, + claude, + shortcut, + fixtures, + }), ); if (!skipWidgetStatic) { widgetRouter.use(buildWidgetRouter()); @@ -81,7 +93,7 @@ export function createApp({ { port: config.port, claude: claude?.mode ?? config.claude.mode, - shortcut: config.shortcut.mode, + shortcut: shortcut?.mode ?? config.shortcut.mode, github: config.github.mode, demoMode: config.demoMode, widgetOriginsConfigured: config.cors.widgetOrigins.length, diff --git a/apps/api/src/clients/shortcut.mock.test.ts b/apps/api/src/clients/shortcut.mock.test.ts new file mode 100644 index 0000000..d8a455f --- /dev/null +++ b/apps/api/src/clients/shortcut.mock.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { createMockShortcutClient } from "./shortcut.mock.js"; + +const draft = { + title: "Support: x", + body: "body", + storyType: "bug" as const, + labels: ["support-agent"], +}; + +describe("createMockShortcutClient", () => { + it("returns a mock-provider ticket with a short id + example URL", async () => { + const client = createMockShortcutClient(); + const ticket = await client.createStory(draft); + expect(ticket.provider).toBe("mock"); + expect(ticket.ticketId).toMatch(/^MOCK-[0-9A-F]{8}$/); + expect(ticket.ticketUrl).toContain(ticket.ticketId); + }); + + it("records every draft in the `created` buffer", async () => { + const client = createMockShortcutClient(); + await client.createStory(draft); + await client.createStory({ ...draft, title: "Support: y" }); + expect(client.created).toHaveLength(2); + expect(client.created[0].title).toBe("Support: x"); + expect(client.created[1].title).toBe("Support: y"); + }); + + it("reset() clears the buffer", async () => { + const client = createMockShortcutClient(); + await client.createStory(draft); + client.reset(); + expect(client.created).toHaveLength(0); + }); +}); diff --git a/apps/api/src/clients/shortcut.mock.ts b/apps/api/src/clients/shortcut.mock.ts new file mode 100644 index 0000000..b33f8eb --- /dev/null +++ b/apps/api/src/clients/shortcut.mock.ts @@ -0,0 +1,34 @@ +import { randomUUID } from "node:crypto"; +import type { TicketDraft, TicketSummary } from "@ai-support/shared"; +import type { ShortcutClient } from "./shortcut.js"; + +/** + * Mock ShortcutClient. Returns a deterministic-ish fake ticket (new UUID + * each call but a predictable URL shape) and records every draft it sees + * in an internal buffer — useful for tests that want to assert what + * would have been posted to Shortcut. + */ +export interface MockShortcutClient extends ShortcutClient { + created: TicketDraft[]; + reset(): void; +} + +export function createMockShortcutClient(): MockShortcutClient { + const created: TicketDraft[] = []; + return { + mode: "mock", + created, + reset() { + created.length = 0; + }, + async createStory(draft: TicketDraft): Promise { + created.push(draft); + const id = `MOCK-${randomUUID().slice(0, 8).toUpperCase()}`; + return { + ticketId: id, + ticketUrl: `https://example.test/tickets/${id}`, + provider: "mock", + }; + }, + }; +} diff --git a/apps/api/src/clients/shortcut.test.ts b/apps/api/src/clients/shortcut.test.ts new file mode 100644 index 0000000..81f0a2a --- /dev/null +++ b/apps/api/src/clients/shortcut.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import { createLiveShortcutClient } from "./shortcut.js"; + +const draft = { + title: "Support: basket empty", + body: "## Reported issue\nfoo", + storyType: "bug" as const, + labels: ["support-agent", "confidence-high"], +}; + +function mockFetchOk( + status = 201, + body: unknown = { id: 42, app_url: "https://app.shortcut.com/x/story/42" }, +) { + return vi.fn( + async () => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); +} + +describe("createLiveShortcutClient", () => { + it("POSTs to /stories with the draft and the Shortcut-Token header", async () => { + const fetchImpl = mockFetchOk(); + const client = createLiveShortcutClient({ + apiToken: "tok_test", + fetchImpl, + }); + const ticket = await client.createStory(draft); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + const call = fetchImpl.mock.calls[0]; + const url = call[0] as string; + const init = call[1] as RequestInit; + expect(url).toContain("/stories"); + expect((init.headers as Record)["Shortcut-Token"]).toBe( + "tok_test", + ); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + name: draft.title, + description: draft.body, + story_type: "bug", + labels: [{ name: "support-agent" }, { name: "confidence-high" }], + }); + expect(ticket).toEqual({ + ticketId: "42", + ticketUrl: "https://app.shortcut.com/x/story/42", + provider: "shortcut", + }); + }); + + it("includes workflow_state_id when configured", async () => { + const fetchImpl = mockFetchOk(); + const client = createLiveShortcutClient({ + apiToken: "tok", + workflowStateId: 123, + fetchImpl, + }); + await client.createStory(draft); + const body = JSON.parse(fetchImpl.mock.calls[0][1]?.body as string); + expect(body.workflow_state_id).toBe(123); + }); + + it("throws a readable error on non-2xx", async () => { + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify({ message: "unauthorized" }), { + status: 401, + }), + ); + const client = createLiveShortcutClient({ apiToken: "tok", fetchImpl }); + await expect(client.createStory(draft)).rejects.toThrow(/shortcut 401/); + }); + + it("throws when response is missing id/app_url", async () => { + const fetchImpl = mockFetchOk(201, { id: null, app_url: null }); + const client = createLiveShortcutClient({ apiToken: "tok", fetchImpl }); + await expect(client.createStory(draft)).rejects.toThrow( + /missing id or app_url/, + ); + }); +}); diff --git a/apps/api/src/clients/shortcut.ts b/apps/api/src/clients/shortcut.ts new file mode 100644 index 0000000..73658cb --- /dev/null +++ b/apps/api/src/clients/shortcut.ts @@ -0,0 +1,97 @@ +import type { TicketDraft, TicketSummary } from "@ai-support/shared"; +import type { Logger } from "../logger.js"; + +/** + * ShortcutClient creates a support ticket from a TicketDraft. Live and + * mock implementations share this shape so the ticketing phase does not + * need to know which one is wired up. + */ +export interface ShortcutClient { + createStory(draft: TicketDraft): Promise; + mode: "live" | "mock"; +} + +export interface CreateLiveShortcutClientOptions { + apiToken: string; + /** Required by most Shortcut workspaces — optional here with a clear error. */ + workflowStateId?: number; + /** Base URL override (for tests); defaults to production. */ + apiBase?: string; + /** 5s default keeps the pipeline snappy; live failures fall back gracefully. */ + timeoutMs?: number; + logger?: Logger; + fetchImpl?: typeof fetch; +} + +const DEFAULT_API_BASE = "https://api.app.shortcut.com/api/v3"; +const DEFAULT_TIMEOUT_MS = 5_000; + +interface ShortcutStoryResponse { + id: number; + app_url: string; +} + +/** + * Live Shortcut client — POSTs to /api/v3/stories with the + * `Shortcut-Token` header. Throws on network / auth / 4xx-5xx failures + * so the orchestrator surfaces `ticketing: failed` and the rest of the + * pipeline continues. + */ +export function createLiveShortcutClient( + opts: CreateLiveShortcutClientOptions, +): ShortcutClient { + const apiBase = opts.apiBase ?? DEFAULT_API_BASE; + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const doFetch = opts.fetchImpl ?? fetch; + + return { + mode: "live", + async createStory(draft: TicketDraft): Promise { + const body: Record = { + name: draft.title, + description: draft.body, + story_type: draft.storyType, + labels: draft.labels.map((name) => ({ name })), + }; + if (opts.workflowStateId !== undefined) { + body.workflow_state_id = opts.workflowStateId; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await doFetch(`${apiBase}/stories`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Shortcut-Token": opts.apiToken, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`shortcut ${res.status}: ${text.slice(0, 240)}`); + } + const json = (await res.json()) as Partial; + if (!json.id || !json.app_url) { + throw new Error( + "shortcut: response missing id or app_url — API contract changed?", + ); + } + opts.logger?.info( + { ticketId: json.id, ticketUrl: json.app_url }, + "shortcut: story created", + ); + return { + ticketId: String(json.id), + ticketUrl: json.app_url, + provider: "shortcut", + }; + } finally { + clearTimeout(timer); + } + }, + }; +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 419cdef..540d155 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -35,6 +35,13 @@ const EnvSchema = z.object({ DASHBOARD_ORIGIN: z.string().optional(), SHORTCUT_API_TOKEN: z.string().optional(), SHORTCUT_WORKSPACE: z.string().optional(), + SHORTCUT_WORKFLOW_STATE_ID: z + .string() + .optional() + .transform((v) => (v === undefined ? undefined : Number(v))) + .refine((n) => n === undefined || Number.isFinite(n), { + message: "SHORTCUT_WORKFLOW_STATE_ID must be numeric", + }), GITHUB_TOKEN: z.string().optional(), GITHUB_REPO: z.string().optional(), }); @@ -52,7 +59,12 @@ export interface AppConfig { claude: { mode: "live" | "mock" }; shortcut: | { mode: "mock" } - | { mode: "live"; token: string; workspace?: string }; + | { + mode: "live"; + token: string; + workspace?: string; + workflowStateId?: number; + }; github: { mode: "mock" } | { mode: "live"; token: string; repo: string }; } @@ -68,6 +80,7 @@ export function parseConfig(raw: NodeJS.ProcessEnv): AppConfig { mode: "live", token: env.SHORTCUT_API_TOKEN, workspace: env.SHORTCUT_WORKSPACE, + workflowStateId: env.SHORTCUT_WORKFLOW_STATE_ID, } : { mode: "mock" }; diff --git a/apps/api/src/orchestrator/composeTicket.test.ts b/apps/api/src/orchestrator/composeTicket.test.ts new file mode 100644 index 0000000..c816b4a --- /dev/null +++ b/apps/api/src/orchestrator/composeTicket.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { CodeInvestigationResult } from "./codeInvestigation.js"; +import { composeTicket } from "./composeTicket.js"; +import type { IntakeResult } from "./intake.js"; +import type { ResolutionResult } from "./resolution.js"; + +const intake: IntakeResult = { + sessionId: "s-abc", + normalized: "my basket is empty on tesco after pushing", + originalMessage: "my basket is empty on Tesco after pushing!", +}; + +const resolution: ResolutionResult = { + explanation: "Expired retailer session.", + workaround: "Reconnect Tesco and retry.", + confidence: "high", + citations: ["known-issues.md", "troubleshooting.md"], +}; + +const investigation: CodeInvestigationResult = { + rootCause: "tesco.ts treats 200-empty as success.", + affectedFiles: ["src/retailers/tesco.ts", "src/retailers/asda.ts"], + workaround: "Reconnect.", + confidence: "high", +}; + +describe("composeTicket", () => { + it("uses preformed title+body when provided and keeps labels", () => { + const draft = composeTicket({ + intake, + resolution, + investigation, + preformed: { title: "PREFORMED TITLE", body: "PREFORMED BODY" }, + }); + expect(draft.title).toBe("PREFORMED TITLE"); + expect(draft.body).toBe("PREFORMED BODY"); + expect(draft.storyType).toBe("bug"); + expect(draft.labels).toContain("support-agent"); + expect(draft.labels).toContain("confidence-high"); + expect(draft.labels).toContain("code-investigated"); + }); + + it("composes title + body from pipeline outputs when no preformed", () => { + const draft = composeTicket({ intake, resolution, investigation }); + expect(draft.title.startsWith("Support: ")).toBe(true); + expect(draft.title).toContain("basket is empty"); + expect(draft.body).toContain("## Reported issue"); + expect(draft.body).toContain("my basket is empty on Tesco"); + expect(draft.body).toContain("## Probable root cause"); + expect(draft.body).toContain("tesco.ts treats 200-empty"); + expect(draft.body).toContain("## Affected files"); + expect(draft.body).toContain("`src/retailers/tesco.ts`"); + expect(draft.body).toContain("## Workaround"); + expect(draft.body).toContain("## Referenced docs"); + expect(draft.body).toContain("known-issues.md"); + expect(draft.body).toContain("## Metadata"); + expect(draft.body).toContain("Confidence: **high**"); + expect(draft.body).toContain("`s-abc`"); + }); + + it("uses docs-only label when investigation is null", () => { + const draft = composeTicket({ intake, resolution, investigation: null }); + expect(draft.labels).toContain("docs-only"); + expect(draft.labels).not.toContain("code-investigated"); + expect(draft.body).not.toContain("## Probable root cause"); + expect(draft.body).not.toContain("## Affected files"); + }); + + it("truncates long reported-issue text with an ellipsis", () => { + const long = "x".repeat(2000); + const draft = composeTicket({ + intake: { ...intake, originalMessage: long, normalized: long }, + resolution, + investigation: null, + }); + expect(draft.body).toMatch(/…$|…\n/m); + expect(draft.body.length).toBeLessThan(long.length); + }); +}); diff --git a/apps/api/src/orchestrator/composeTicket.ts b/apps/api/src/orchestrator/composeTicket.ts new file mode 100644 index 0000000..32286c6 --- /dev/null +++ b/apps/api/src/orchestrator/composeTicket.ts @@ -0,0 +1,87 @@ +import type { TicketDraft } from "@ai-support/shared"; +import type { CodeInvestigationResult } from "./codeInvestigation.js"; +import type { IntakeResult } from "./intake.js"; +import type { ResolutionResult } from "./resolution.js"; + +export interface ComposeTicketInput { + intake: IntakeResult; + resolution: ResolutionResult; + investigation: CodeInvestigationResult | null; + /** Optional fixture-authored title/body — wins over the composed default. */ + preformed?: { title: string; body: string }; +} + +/** + * Builds a structured ticket draft from pipeline outputs. Used by both the + * live Shortcut client (which POSTs the draft) and the mock client (which + * just echoes an identity). Pure function — trivial to unit test. + */ +export function composeTicket(input: ComposeTicketInput): TicketDraft { + const { intake, resolution, investigation, preformed } = input; + + const labels = [ + "support-agent", + `confidence-${resolution.confidence}`, + investigation ? "code-investigated" : "docs-only", + ]; + + if (preformed) { + return { + title: preformed.title, + body: preformed.body, + storyType: "bug", + labels, + }; + } + + const title = buildTitle(intake); + const body = buildBody(intake, resolution, investigation); + + return { title, body, storyType: "bug", labels }; +} + +function buildTitle(intake: IntakeResult): string { + const head = intake.normalized.slice(0, 90).trim(); + return `Support: ${head}${intake.normalized.length > 90 ? "…" : ""}`; +} + +function buildBody( + intake: IntakeResult, + resolution: ResolutionResult, + investigation: CodeInvestigationResult | null, +): string { + const sections: string[] = []; + + sections.push( + `## Reported issue\n\n> ${truncate(intake.originalMessage, 1500)}`, + ); + + sections.push(`## Agent analysis\n\n${resolution.explanation}`); + + if (investigation) { + sections.push(`## Probable root cause\n\n${investigation.rootCause}`); + if (investigation.affectedFiles.length > 0) { + const list = investigation.affectedFiles + .map((f) => `- \`${f}\``) + .join("\n"); + sections.push(`## Affected files\n\n${list}`); + } + } + + sections.push(`## Workaround\n\n${resolution.workaround}`); + + if (resolution.citations.length > 0) { + const list = resolution.citations.map((c) => `- ${c}`).join("\n"); + sections.push(`## Referenced docs\n\n${list}`); + } + + sections.push( + `## Metadata\n\n- Confidence: **${resolution.confidence}**\n- Session: \`${intake.sessionId}\``, + ); + + return sections.join("\n\n"); +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max - 1)}…`; +} diff --git a/apps/api/src/orchestrator/index.ts b/apps/api/src/orchestrator/index.ts index 12cc319..a714021 100644 --- a/apps/api/src/orchestrator/index.ts +++ b/apps/api/src/orchestrator/index.ts @@ -41,7 +41,11 @@ export interface PipelineOverrides { r: RouterDecision, c: CodeInvestigationResult | null, ) => Promise; - ticketing?: (r: ResolutionResult) => Promise; + ticketing?: ( + r: ResolutionResult, + c: CodeInvestigationResult | null, + i: IntakeResult, + ) => Promise; /** Returns null when the scenario has no PR (treated as skipped by the orchestrator). */ openFixPR?: (r: ResolutionResult) => Promise; } @@ -132,7 +136,7 @@ export async function runPipeline( try { ticket = await timed("ticketing", async () => { const fn = ctx.overrides?.ticketing ?? runTicketing; - return fn(resolution); + return fn(resolution, investigation, intake); }); } catch { // ticketing already emitted a failed event via `timed()` diff --git a/apps/api/src/orchestrator/ticketing.ts b/apps/api/src/orchestrator/ticketing.ts index 9bbce15..9ef2b0e 100644 --- a/apps/api/src/orchestrator/ticketing.ts +++ b/apps/api/src/orchestrator/ticketing.ts @@ -1,13 +1,18 @@ import { randomUUID } from "node:crypto"; import type { TicketSummary } from "@ai-support/shared"; +import type { CodeInvestigationResult } from "./codeInvestigation.js"; +import type { IntakeResult } from "./intake.js"; import type { ResolutionResult } from "./resolution.js"; /** - * Phase stub — real Shortcut API integration lands in Phase 7. - * Returns a mock ticket so the pipeline always produces a usable response. + * Default (fallback) ticketing implementation — returns a mock ticket. + * The real flow passes a `ticketing` override built around a ShortcutClient + * plus composeTicket(); this stub only runs when no override is wired. */ export async function runTicketing( _resolution: ResolutionResult, + _investigation: CodeInvestigationResult | null, + _intake: IntakeResult, ): Promise { const id = `MOCK-${randomUUID().slice(0, 8)}`; return { diff --git a/apps/api/src/routes/chat.ts b/apps/api/src/routes/chat.ts index 00c0f5d..d59a036 100644 --- a/apps/api/src/routes/chat.ts +++ b/apps/api/src/routes/chat.ts @@ -3,13 +3,11 @@ import type { ChatMessage, ChatResponse, PhaseEvent } from "@ai-support/shared"; import { Router, type Router as RouterType } from "express"; import { z } from "zod"; import type { ClaudeClient } from "../clients/claude.js"; +import type { ShortcutClient } from "../clients/shortcut.js"; import type { AppConfig } from "../config.js"; -import { - type FixtureLibrary, - buildMockPR, - buildMockTicket, -} from "../fixtures/index.js"; +import { type FixtureLibrary, buildMockPR } from "../fixtures/index.js"; import type { Logger } from "../logger.js"; +import { composeTicket } from "../orchestrator/composeTicket.js"; import { type PipelineOverrides, runPipeline } from "../orchestrator/index.js"; import type { Retriever } from "../rag/indexer.js"; import type { SessionStore } from "../sessions/store.js"; @@ -31,16 +29,19 @@ export interface ChatRouterDeps { sessions: SessionStore; retriever?: Retriever; claude?: ClaudeClient; + shortcut?: ShortcutClient; fixtures?: FixtureLibrary; } export function buildChatRouter(deps: ChatRouterDeps): RouterType { - const { config, logger, sessions, retriever, claude, fixtures } = deps; + const { config, logger, sessions, retriever, claude, shortcut, fixtures } = + deps; const router: RouterType = Router(); const overrides = buildPipelineOverrides({ retriever, claude, + shortcut, fixtures, }); @@ -160,14 +161,15 @@ export function buildChatRouter(deps: ChatRouterDeps): RouterType { interface OverridesDeps { retriever?: Retriever; claude?: ClaudeClient; + shortcut?: ShortcutClient; fixtures?: FixtureLibrary; } function buildPipelineOverrides( deps: OverridesDeps, ): PipelineOverrides | undefined { - const { retriever, claude, fixtures } = deps; - const hasAny = retriever || claude || fixtures; + const { retriever, claude, shortcut, fixtures } = deps; + const hasAny = retriever || claude || shortcut || fixtures; if (!hasAny) return undefined; const overrides: PipelineOverrides = {}; @@ -186,12 +188,24 @@ function buildPipelineOverrides( claude.synthesize(intake, retrieved, decision, investigation); } - if (fixtures) { - // Ticket + PR metadata come from whichever fixture matched the intake. - overrides.ticketing = async (resolution) => { - const fix = findFixtureByResolution(fixtures, resolution); - return buildMockTicket(fix ?? fixtures.fallback()); + if (shortcut) { + overrides.ticketing = async (resolution, investigation, intake) => { + // If a fixture matches this scenario and provides a preformed + // title/body, use it — demo-quality wording takes precedence. + const fix = fixtures + ? findFixtureByResolution(fixtures, resolution) + : undefined; + const draft = composeTicket({ + intake, + resolution, + investigation, + preformed: fix?.ticket, + }); + return shortcut.createStory(draft); }; + } + + if (fixtures) { overrides.openFixPR = async (resolution) => { const fix = findFixtureByResolution(fixtures, resolution); return fix ? (buildMockPR(fix) ?? null) : null; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index abcb058..fed9fca 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -5,6 +5,11 @@ import { createApp } from "./app.js"; import type { ClaudeClient } from "./clients/claude.js"; import { createLiveClaudeClient } from "./clients/claude.js"; import { createMockClaudeClient } from "./clients/claude.mock.js"; +import { + type ShortcutClient, + createLiveShortcutClient, +} from "./clients/shortcut.js"; +import { createMockShortcutClient } from "./clients/shortcut.mock.js"; import { parseConfig } from "./config.js"; import { type FixtureLibrary, loadFixtures } from "./fixtures/index.js"; import { createLogger } from "./logger.js"; @@ -113,7 +118,33 @@ if (claudeMode === "live" && productRepo.path) { logger.warn("claude: mock client active with no fixtures"); } -const app = createApp({ config, logger, retriever, claude, fixtures }); +// Pick the Shortcut client. Live mode requires both SHORTCUT_API_TOKEN +// (already in config) and — for most workspaces — a workflow_state_id. In +// every other case we use the deterministic mock. +let shortcut: ShortcutClient; +if (config.shortcut.mode === "live") { + shortcut = createLiveShortcutClient({ + apiToken: config.shortcut.token, + workflowStateId: config.shortcut.workflowStateId, + logger, + }); + logger.info( + { workflowStateId: config.shortcut.workflowStateId ?? "(none)" }, + "shortcut: live client active", + ); +} else { + shortcut = createMockShortcutClient(); + logger.info("shortcut: mock client active"); +} + +const app = createApp({ + config, + logger, + retriever, + claude, + shortcut, + fixtures, +}); app.listen(config.port, () => { logger.info({ port: config.port }, "api listening"); diff --git a/packages/shared/src/ticket.ts b/packages/shared/src/ticket.ts index 23f5888..7d8db2d 100644 --- a/packages/shared/src/ticket.ts +++ b/packages/shared/src/ticket.ts @@ -9,3 +9,16 @@ export interface PRSummary { branch: string; provider: "github" | "mock"; } + +export type StoryType = "bug" | "chore" | "feature"; + +/** + * Platform-agnostic ticket draft. The ticketing phase composes one of + * these; a ShortcutClient (live or mock) creates the actual story. + */ +export interface TicketDraft { + title: string; + body: string; + storyType: StoryType; + labels: string[]; +}