Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 41cbc27

Browse files
refactor(core): extract shared presentation semantics (#3635)
1 parent a52ddb0 commit 41cbc27

20 files changed

Lines changed: 444 additions & 280 deletions

File tree

packages/api-client/src/posthog-client.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,33 @@ describe("PostHogAPIClient", () => {
475475
);
476476
});
477477

478+
it.each([true, false])("forwards auto publish %s", async (autoPublish) => {
479+
const client = new PostHogAPIClient(
480+
"http://localhost:8000",
481+
async () => "token",
482+
async () => "token",
483+
123,
484+
);
485+
const post = vi.fn().mockResolvedValue({
486+
id: "task-123",
487+
title: "Task",
488+
description: "Task",
489+
created_at: "2026-04-14T00:00:00Z",
490+
updated_at: "2026-04-14T00:00:00Z",
491+
origin_product: "user_created",
492+
});
493+
(client as unknown as { api: { post: typeof post } }).api = { post };
494+
495+
await client.runTaskInCloud("task-123", null, { autoPublish });
496+
497+
expect(post).toHaveBeenCalledWith(
498+
"/api/projects/{project_id}/tasks/{id}/run/",
499+
expect.objectContaining({
500+
body: expect.objectContaining({ auto_publish: autoPublish }),
501+
}),
502+
);
503+
});
504+
478505
it("rejects unsupported reasoning effort for cloud Codex runs", async () => {
479506
const client = new PostHogAPIClient(
480507
"http://localhost:8000",

packages/api-client/src/posthog-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ function buildCloudRunRequestBody(
787787
if (options?.prAuthorshipMode) {
788788
body.pr_authorship_mode = options.prAuthorshipMode;
789789
}
790-
if (options?.autoPublish) {
790+
if (options?.autoPublish !== undefined) {
791791
body.auto_publish = options.autoPublish;
792792
}
793793
if (options?.rtkEnabled === false) {

packages/api-client/src/types.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,33 @@ export type McpAuthType = Schemas.MCPAuthTypeEnum;
88
export type McpRecommendedServer = Schemas.MCPServerTemplate;
99
export type McpServerInstallation = Schemas.MCPServerInstallation;
1010
export type McpInstallationTool = Schemas.MCPServerInstallationTool;
11+
export type McpOAuthRedirectResponse = Schemas.OAuthRedirectResponse;
12+
export type McpInstallSource = "posthog" | "posthog-code" | "posthog-mobile";
13+
export type McpInstallResponse =
14+
| McpServerInstallation
15+
| McpOAuthRedirectResponse;
16+
17+
export interface InstallCustomMcpServerOptions {
18+
name: string;
19+
url: string;
20+
auth_type: McpAuthType;
21+
api_key?: string;
22+
description?: string;
23+
client_id?: string;
24+
client_secret?: string;
25+
install_source?: McpInstallSource;
26+
posthog_code_callback_url?: string;
27+
}
28+
29+
export interface InstallMcpTemplateOptions {
30+
template_id: string;
31+
api_key?: string;
32+
install_source?: McpInstallSource;
33+
posthog_code_callback_url?: string;
34+
}
35+
36+
export interface UpdateMcpServerInstallationOptions {
37+
display_name?: string;
38+
description?: string;
39+
is_enabled?: boolean;
40+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { TaskAutomation } from "@posthog/shared";
2+
3+
export const SKILL_TEMPLATE_ID_PREFIX = "llm-skill:";
4+
5+
export function formatSkillTemplateId(skillName: string): string {
6+
return `${SKILL_TEMPLATE_ID_PREFIX}${skillName.trim()}`;
7+
}
8+
9+
export function parseSkillTemplateId(
10+
templateId: string | null | undefined,
11+
): string | null {
12+
if (!templateId?.startsWith(SKILL_TEMPLATE_ID_PREFIX)) return null;
13+
const skillName = templateId.slice(SKILL_TEMPLATE_ID_PREFIX.length).trim();
14+
return skillName || null;
15+
}
16+
17+
export interface AutomationTemplatePresentation {
18+
templateName: string | null;
19+
repositoryLabel: string | null;
20+
contextLabel: string | null;
21+
secondaryLabel: string;
22+
}
23+
24+
export function getAutomationTemplatePresentation(
25+
automation: Pick<TaskAutomation, "repository" | "template_id">,
26+
): AutomationTemplatePresentation {
27+
const repositoryLabel = automation.repository.trim() || null;
28+
const skillName = parseSkillTemplateId(automation.template_id);
29+
const contextLabel = skillName ? "Skill store" : null;
30+
return {
31+
templateName:
32+
skillName ?? (automation.template_id ? "Template automation" : null),
33+
repositoryLabel,
34+
contextLabel,
35+
secondaryLabel: repositoryLabel ?? contextLabel ?? "No repository context",
36+
};
37+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { AnySignalReportArtefact } from "@posthog/shared/domain-types";
2+
3+
export type ActivityArtefact = Extract<
4+
AnySignalReportArtefact,
5+
{ type: "commit" | "task_run" }
6+
>;
7+
8+
export function selectActivityArtefacts(
9+
artefacts: AnySignalReportArtefact[],
10+
): ActivityArtefact[] {
11+
return artefacts
12+
.filter(
13+
(artefact): artefact is ActivityArtefact =>
14+
artefact.type === "commit" || artefact.type === "task_run",
15+
)
16+
.sort((left, right) => left.created_at.localeCompare(right.created_at));
17+
}
18+
19+
export function shortSha(sha: string): string {
20+
return sha.slice(0, 12);
21+
}
22+
23+
const SIGNALS_TYPE_LABELS: Record<string, string> = {
24+
research: "Research",
25+
implementation: "Implementation",
26+
repo_selection: "Repo selection",
27+
};
28+
29+
export function humanizeIdentifier(value: string): string {
30+
const spaced = value.replace(/[_-]+/g, " ").trim();
31+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
32+
}
33+
34+
export function taskRunLabel(content: {
35+
product: string;
36+
type: string;
37+
}): string {
38+
return content.product === "signals"
39+
? (SIGNALS_TYPE_LABELS[content.type] ?? humanizeIdentifier(content.type))
40+
: humanizeIdentifier(content.type);
41+
}
42+
43+
export function attributionLabel(artefact: {
44+
created_by?: { first_name?: string; email: string } | null;
45+
task_id?: string | null;
46+
}): string | null {
47+
if (artefact.created_by) {
48+
return artefact.created_by.first_name?.trim() || artefact.created_by.email;
49+
}
50+
return artefact.task_id ? "agent" : null;
51+
}
52+
53+
export type DiffLineKind = "add" | "del" | "hunk" | "context";
54+
55+
export interface DiffLine {
56+
text: string;
57+
kind: DiffLineKind;
58+
}
59+
60+
export function parseDiffLines(diff: string): DiffLine[] {
61+
return diff
62+
.replace(/\n$/, "")
63+
.split("\n")
64+
.map((text) => {
65+
if (text.startsWith("+") && !text.startsWith("+++")) {
66+
return { text, kind: "add" as const };
67+
}
68+
if (text.startsWith("-") && !text.startsWith("---")) {
69+
return { text, kind: "del" as const };
70+
}
71+
if (text.startsWith("@@")) return { text, kind: "hunk" as const };
72+
return { text, kind: "context" as const };
73+
});
74+
}

packages/core/src/inbox/engagement.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,16 @@ export function buildBulkActionEvents(
174174
export interface InboxViewedFilterState {
175175
sourceProductFilter: string[];
176176
priorityFilter: string[];
177-
searchQuery: string;
177+
searchQuery?: string;
178+
statusFilter?: readonly string[];
179+
defaultStatusFilter?: readonly string[];
180+
suggestedReviewerFilter?: string[];
178181
/**
179182
* True when the reviewer scope is the default ("For you"). False when the
180183
* user has narrowed to a teammate or the whole project — treated as an
181184
* active filter for `has_active_filters`.
182185
*/
183-
isDefaultScope: boolean;
186+
isDefaultScope?: boolean;
184187
}
185188

186189
export interface BuildInboxViewedInput {
@@ -192,7 +195,7 @@ export interface BuildInboxViewedInput {
192195
/** Server-reported total of reports matching the active query — the headline inbox number. */
193196
totalCount: number;
194197
/** Tab badge counts shown in the v2 header (the numbers the user actually sees). */
195-
tabCounts: { pulls: number; reports: number };
198+
tabCounts?: { pulls: number; reports: number };
196199
filters: InboxViewedFilterState;
197200
}
198201

@@ -207,7 +210,8 @@ export interface BuildInboxViewedInput {
207210
export function buildInboxViewedProperties(
208211
input: BuildInboxViewedInput,
209212
): InboxViewedProperties {
210-
const { visibleReports, totalCount, tabCounts, filters } = input;
213+
const { visibleReports, totalCount, filters } = input;
214+
const tabCounts = input.tabCounts ?? { pulls: 0, reports: totalCount };
211215

212216
const priorityCounts = { P0: 0, P1: 0, P2: 0, P3: 0, P4: 0, unknown: 0 };
213217
const actionabilityCounts = {
@@ -237,19 +241,28 @@ export function buildInboxViewedProperties(
237241
}
238242
}
239243

244+
const statusFiltered =
245+
filters.statusFilter !== undefined &&
246+
filters.defaultStatusFilter !== undefined &&
247+
(filters.statusFilter.length !== filters.defaultStatusFilter.length ||
248+
filters.statusFilter.some(
249+
(status) => !filters.defaultStatusFilter?.includes(status),
250+
));
240251
const hasActiveFilters =
241252
filters.sourceProductFilter.length > 0 ||
242253
filters.priorityFilter.length > 0 ||
243-
filters.searchQuery.trim().length > 0 ||
244-
!filters.isDefaultScope;
254+
(filters.searchQuery?.trim().length ?? 0) > 0 ||
255+
statusFiltered ||
256+
(filters.suggestedReviewerFilter?.length ?? 0) > 0 ||
257+
filters.isDefaultScope === false;
245258

246259
return {
247260
report_count: visibleReports.length,
248261
total_count: totalCount,
249262
ready_count: readyCount,
250263
has_active_filters: hasActiveFilters,
251264
source_product_filter: filters.sourceProductFilter,
252-
status_filter_count: 0,
265+
status_filter_count: filters.statusFilter?.length ?? 0,
253266
is_empty: totalCount === 0,
254267
priority_p0_count: priorityCounts.P0,
255268
priority_p1_count: priorityCounts.P1,

packages/core/src/inbox/reportMembership.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@ export function isDismissedReport(report: SignalReport): boolean {
3535
return report.status === "suppressed" || report.status === "resolved";
3636
}
3737

38+
export function isRestorableReport(
39+
report: Pick<SignalReport, "status">,
40+
): boolean {
41+
return report.status === "suppressed";
42+
}
43+
44+
export function getImmediatelyActionableReports(
45+
reports: SignalReport[],
46+
): SignalReport[] {
47+
return reports.filter(
48+
(report) =>
49+
report.status === "ready" &&
50+
report.actionability === "immediately_actionable" &&
51+
!report.already_addressed,
52+
);
53+
}
54+
3855
export type InboxScope = "for-you" | "entire-project" | `teammate:${string}`;
3956

4057
export const INBOX_SCOPE_FOR_YOU: InboxScope = "for-you";
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function isMcpOAuthRedirect(
2+
response: object,
3+
): response is { redirect_url: string } {
4+
return (
5+
"redirect_url" in response && typeof response.redirect_url === "string"
6+
);
7+
}
8+
9+
export function isStdioMcpServer(server: {
10+
transport_type?: string | null;
11+
}): boolean {
12+
return server.transport_type === "stdio";
13+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { parseMcpToolName } from "@posthog/shared";
2+
3+
const POSTHOG_SERVER_RE = /^(?:plugin_)?posthog(?:_[^_]+)*$/;
4+
const POSTHOG_VERB_RE =
5+
/^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/;
6+
const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/;
7+
const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/;
8+
9+
export interface PostHogExecDisplay {
10+
label: string;
11+
input?: string;
12+
}
13+
14+
export function isPostHogExecTool(toolName: string): boolean {
15+
const mcp = parseMcpToolName(toolName);
16+
return !!mcp && mcp.tool === "exec" && POSTHOG_SERVER_RE.test(mcp.server);
17+
}
18+
19+
export function getPostHogExecDisplay(
20+
toolInput: unknown,
21+
): PostHogExecDisplay | null {
22+
if (!toolInput || typeof toolInput !== "object") return null;
23+
const input = toolInput as { command?: unknown; input?: unknown };
24+
if (typeof input.command !== "string") return null;
25+
const match = input.command.match(POSTHOG_VERB_RE);
26+
if (!match) return null;
27+
const verb = match[1] as "tools" | "search" | "info" | "schema" | "call";
28+
const rest = (match[2] ?? "").trim();
29+
const explicitInput = readExplicitInput(input.input);
30+
31+
switch (verb) {
32+
case "tools":
33+
return { label: "List tools", input: undefined };
34+
case "search":
35+
return {
36+
label: "Search tools",
37+
input: explicitInput ?? (rest || undefined),
38+
};
39+
case "info":
40+
return { label: rest ? `Read ${rest}` : "Read tool", input: undefined };
41+
case "schema": {
42+
const schema = rest.match(POSTHOG_TOOL_NAME_RE);
43+
if (!schema) return { label: "Inspect schema", input: undefined };
44+
const path = explicitInput ?? ((schema[2] ?? "").trim() || undefined);
45+
return {
46+
label: path
47+
? `Inspect ${schema[1]}.${path}`
48+
: `Inspect ${schema[1]} fields`,
49+
input: undefined,
50+
};
51+
}
52+
case "call": {
53+
const call = rest.match(POSTHOG_CALL_BODY_RE);
54+
if (!call) return null;
55+
return {
56+
label: call[1],
57+
input: explicitInput ?? ((call[2] ?? "").trim() || undefined),
58+
};
59+
}
60+
}
61+
}
62+
63+
function readExplicitInput(value: unknown): string | undefined {
64+
if (value === undefined || value === null) return undefined;
65+
if (typeof value === "string") return value.trim() || undefined;
66+
try {
67+
return JSON.stringify(value);
68+
} catch {
69+
return undefined;
70+
}
71+
}
72+
73+
export function formatPosthogExecBody(
74+
input: string | undefined,
75+
): string | undefined {
76+
if (!input) return undefined;
77+
try {
78+
const parsed = JSON.parse(input);
79+
if (parsed && typeof parsed === "object")
80+
return JSON.stringify(parsed, null, 2);
81+
} catch {
82+
return input;
83+
}
84+
return input;
85+
}

0 commit comments

Comments
 (0)