Skip to content
Open
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
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