Skip to content
1 change: 0 additions & 1 deletion packages/api-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"src/**/*"
],
"dependencies": {
"@posthog/agent": "workspace:*",
"@posthog/shared": "workspace:*"
}
}
39 changes: 39 additions & 0 deletions packages/api-client/src/fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,45 @@ describe("buildApiFetcher", () => {
expect(mockFetch.mock.calls[0][1].headers.get("Authorization")).toBe(
"Bearer my-token",
);
expect(mockFetch.mock.calls[0][1].headers.get("User-Agent")).toBe(
"posthog/desktop.hog.dev; version: test",
);
});

it("uses an injected fetch implementation and custom user agent", async () => {
const injectedFetch = vi.fn().mockResolvedValueOnce(ok());
const fetcher = buildApiFetcher({
getAccessToken: vi.fn().mockResolvedValue("token"),
refreshAccessToken: vi.fn().mockResolvedValue("new-token"),
appVersion: "1.2.3",
fetch: injectedFetch,
userAgent: "posthog/mobile; version: 1.2.3",
});

await fetcher.fetch(mockInput);

expect(injectedFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).not.toHaveBeenCalled();
expect(injectedFetch.mock.calls[0][1].headers.get("User-Agent")).toBe(
"posthog/mobile; version: 1.2.3",
);
});

it("omits the user agent when explicitly disabled", async () => {
const injectedFetch = vi.fn().mockResolvedValueOnce(ok());
const fetcher = buildApiFetcher({
getAccessToken: vi.fn().mockResolvedValue("token"),
refreshAccessToken: vi.fn().mockResolvedValue("new-token"),
appVersion: "1.2.3",
fetch: injectedFetch,
userAgent: null,
});

await fetcher.fetch(mockInput);

expect(injectedFetch.mock.calls[0][1].headers.has("User-Agent")).toBe(
false,
);
});

it("retries once with a freshly fetched token on 401", async () => {
Expand Down
25 changes: 21 additions & 4 deletions packages/api-client/src/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import type { createApiClient } from "./generated";

export type FetchImplementation = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;

export type ApiFetcherConfig = {
getAccessToken: () => Promise<string>;
refreshAccessToken: () => Promise<string>;
appVersion: string;
fetch?: FetchImplementation;
userAgent?: string | null;
};

/**
Expand All @@ -13,11 +20,13 @@ export type ApiFetcherConfig = {
*/
export class ApiRequestError extends Error {
readonly status: number;
readonly body: unknown;

constructor(status: number, serializedBody: string) {
constructor(status: number, serializedBody: string, body?: unknown) {
super(`Failed request: [${status}] ${serializedBody}`);
this.name = "ApiRequestError";
this.status = status;
this.body = body;
}
}

Expand All @@ -29,7 +38,11 @@ export function requestErrorStatus(error: unknown): number | undefined {
export const buildApiFetcher: (
config: ApiFetcherConfig,
) => Parameters<typeof createApiClient>[0] = (config) => {
const userAgent = `posthog/desktop.hog.dev; version: ${config.appVersion}`;
const fetchImpl = config.fetch ?? globalThis.fetch;
const userAgent =
config.userAgent === undefined
? `posthog/desktop.hog.dev; version: ${config.appVersion}`
: config.userAgent;

const makeRequest = async (
input: Parameters<Parameters<typeof createApiClient>[0]["fetch"]>[0],
Expand All @@ -38,7 +51,9 @@ export const buildApiFetcher: (
const headers = new Headers();
headers.set("Authorization", `Bearer ${token}`);
headers.set("Content-Type", "application/json");
headers.set("User-Agent", userAgent);
if (userAgent) {
headers.set("User-Agent", userAgent);
}

if (input.urlSearchParams) {
input.url.search = input.urlSearchParams.toString();
Expand All @@ -59,7 +74,7 @@ export const buildApiFetcher: (
}

try {
const response = await fetch(input.url, {
const response = await fetchImpl(input.url, {
method: input.method.toUpperCase(),
...(body && { body }),
headers,
Expand Down Expand Up @@ -114,6 +129,7 @@ export const buildApiFetcher: (
throw new ApiRequestError(
response.status,
JSON.stringify(errorResponse),
errorResponse,
);
}
}
Expand All @@ -128,6 +144,7 @@ export const buildApiFetcher: (
throw new ApiRequestError(
response.status,
JSON.stringify(errorResponse),
errorResponse,
);
}

Expand Down
6 changes: 5 additions & 1 deletion packages/api-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import "./generated.augment";

export { type ApiFetcherConfig, buildApiFetcher } from "./fetcher";
export {
type ApiFetcherConfig,
buildApiFetcher,
type FetchImplementation,
} from "./fetcher";
export { createApiClient, type Schemas } from "./generated";
export {
createLoop,
Expand Down
192 changes: 192 additions & 0 deletions packages/api-client/src/posthog-client.automations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
PostHogAPIClient,
TaskAutomationValidationError,
} from "./posthog-client";

const automationPayload = {
id: "automation-1",
name: "Daily PRs",
prompt: "Check PRs",
repository: "posthog/posthog",
github_integration: 7,
cron_expression: "0 9 * * *",
timezone: "Europe/London",
template_id: "llm-skill:daily-prs",
enabled: true,
last_run_at: null,
last_run_status: null,
last_task_id: null,
last_task_run_id: null,
last_error: null,
created_at: "2026-07-21T00:00:00Z",
updated_at: "2026-07-21T00:00:00Z",
};

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}

describe("PostHogAPIClient task automations", () => {
const fetch = vi.fn();
const client = new PostHogAPIClient(
"https://app.posthog.test",
async () => "access-token",
async () => "refreshed-token",
42,
{ appVersion: "test", fetch },
);

beforeEach(() => {
fetch.mockReset();
});

it("lists automations and normalizes optional response fields", async () => {
const minimalPayload = {
...automationPayload,
github_integration: undefined,
timezone: undefined,
template_id: undefined,
enabled: undefined,
};
fetch.mockResolvedValueOnce(
jsonResponse({
count: 1,
next: null,
previous: null,
results: [minimalPayload],
}),
);

await expect(client.listTaskAutomations()).resolves.toEqual([
expect.objectContaining({
id: "automation-1",
github_integration: null,
timezone: null,
template_id: null,
enabled: true,
}),
]);
expect(fetch).toHaveBeenCalledWith(
new URL(
"https://app.posthog.test/api/projects/42/task_automations/?limit=500",
),
expect.objectContaining({ method: "GET" }),
);
});

it("gets and creates automations through generated endpoints", async () => {
fetch
.mockResolvedValueOnce(jsonResponse(automationPayload))
.mockResolvedValueOnce(jsonResponse(automationPayload, 201));

await expect(client.getTaskAutomation("automation-1")).resolves.toEqual(
automationPayload,
);
await expect(
client.createTaskAutomation({
name: "Daily PRs",
prompt: "Check PRs",
repository: "posthog/posthog",
github_integration: 7,
cron_expression: "0 9 * * *",
timezone: "Europe/London",
template_id: "llm-skill:daily-prs",
enabled: true,
}),
).resolves.toEqual(automationPayload);

expect(fetch).toHaveBeenNthCalledWith(
2,
new URL("https://app.posthog.test/api/projects/42/task_automations/"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({
name: "Daily PRs",
prompt: "Check PRs",
repository: "posthog/posthog",
github_integration: 7,
cron_expression: "0 9 * * *",
timezone: "Europe/London",
template_id: "llm-skill:daily-prs",
enabled: true,
}),
}),
);
});

it("updates, deletes, and runs automations", async () => {
fetch
.mockResolvedValueOnce(
jsonResponse({ ...automationPayload, enabled: false }),
)
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(jsonResponse(automationPayload));

await expect(
client.updateTaskAutomation("automation-1", { enabled: false }),
).resolves.toMatchObject({ enabled: false });
await expect(
client.deleteTaskAutomation("automation-1"),
).resolves.toBeUndefined();
await expect(client.runTaskAutomation("automation-1")).resolves.toEqual(
automationPayload,
);

expect(fetch).toHaveBeenNthCalledWith(
1,
new URL(
"https://app.posthog.test/api/projects/42/task_automations/automation-1/",
),
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ enabled: false }),
}),
);
expect(fetch).toHaveBeenNthCalledWith(
3,
new URL(
"https://app.posthog.test/api/projects/42/task_automations/automation-1/run/",
),
expect.objectContaining({ method: "POST" }),
);
expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined();
});

it("preserves validation detail, code, and field attribution", async () => {
fetch.mockResolvedValueOnce(
new Response(
JSON.stringify({
type: "validation_error",
code: "invalid_input",
detail: "Enter a valid cron expression.",
attr: "cron_expression",
}),
{
status: 400,
statusText: "Bad Request",
headers: { "Content-Type": "application/json" },
},
),
);

const request = client.createTaskAutomation({
name: "Daily PRs",
prompt: "Check PRs",
repository: "posthog/posthog",
cron_expression: "not a cron",
timezone: "Europe/London",
});

await expect(request).rejects.toBeInstanceOf(TaskAutomationValidationError);
await expect(request).rejects.toMatchObject({
status: 400,
code: "invalid_input",
attr: "cron_expression",
message: "Enter a valid cron expression.",
});
});
});
Loading
Loading