Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
16 changes: 14 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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. */
Expand All @@ -36,6 +39,7 @@ export function createApp({
sessions = new SessionStore(),
retriever,
claude,
shortcut,
fixtures,
skipWidgetStatic = false,
}: CreateAppDeps): Express {
Expand All @@ -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());
Expand All @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions apps/api/src/clients/shortcut.mock.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
34 changes: 34 additions & 0 deletions apps/api/src/clients/shortcut.mock.ts
Original file line number Diff line number Diff line change
@@ -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<TicketSummary> {
created.push(draft);
const id = `MOCK-${randomUUID().slice(0, 8).toUpperCase()}`;
return {
ticketId: id,
ticketUrl: `https://example.test/tickets/${id}`,
provider: "mock",
};
},
};
}
85 changes: 85 additions & 0 deletions apps/api/src/clients/shortcut.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>)["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/,
);
});
});
97 changes: 97 additions & 0 deletions apps/api/src/clients/shortcut.ts
Original file line number Diff line number Diff line change
@@ -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<TicketSummary>;
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<TicketSummary> {
const body: Record<string, unknown> = {
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<ShortcutStoryResponse>;
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);
}
},
};
}
15 changes: 14 additions & 1 deletion apps/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand All @@ -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 };
}

Expand All @@ -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" };

Expand Down
Loading
Loading