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
189 changes: 174 additions & 15 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type {
ActionabilityJudgmentArtefact,
PriorityJudgmentArtefact,
SandboxEnvironment,
SandboxEnvironmentInput,
SignalFindingArtefact,
SignalReportArtefact,
SignalReportArtefactsResponse,
SignalReportSignalsResponse,
Expand All @@ -20,15 +23,25 @@ export type McpRecommendedServer = Schemas.RecommendedServer;

export type McpServerInstallation = Schemas.MCPServerInstallation;

export type Evaluation = Schemas.Evaluation;

export interface SignalSourceConfig {
id: string;
source_product:
| "session_replay"
| "llm_analytics"
| "github"
| "linear"
| "zendesk";
source_type: "session_analysis_cluster" | "evaluation" | "issue" | "ticket";
| "zendesk"
| "error_tracking";
source_type:
| "session_analysis_cluster"
| "evaluation"
| "issue"
| "ticket"
| "issue_created"
| "issue_reopened"
| "issue_spiking";
enabled: boolean;
config: Record<string, unknown>;
created_at: string;
Expand Down Expand Up @@ -60,13 +73,132 @@ function optionalString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}

const PRIORITY_VALUES = new Set(["P0", "P1", "P2", "P3", "P4"]);

function normalizePriorityJudgmentArtefact(
value: Record<string, unknown>,
): PriorityJudgmentArtefact | null {
const id = optionalString(value.id);
if (!id) return null;

const contentValue = isObjectRecord(value.content) ? value.content : null;
if (!contentValue) return null;

const priority = optionalString(contentValue.priority);
if (!priority || !PRIORITY_VALUES.has(priority)) return null;

return {
id,
type: "priority_judgment",
created_at: optionalString(value.created_at) ?? new Date(0).toISOString(),
content: {
explanation: optionalString(contentValue.explanation) ?? "",
priority: priority as PriorityJudgmentArtefact["content"]["priority"],
},
};
}

const ACTIONABILITY_VALUES = new Set([
"immediately_actionable",
"requires_human_input",
"not_actionable",
]);

function normalizeActionabilityJudgmentArtefact(
value: Record<string, unknown>,
): ActionabilityJudgmentArtefact | null {
const id = optionalString(value.id);
if (!id) return null;

const contentValue = isObjectRecord(value.content) ? value.content : null;
if (!contentValue) return null;

// Support both agentic ("actionability") and legacy ("choice") field names
const actionability =
optionalString(contentValue.actionability) ??
optionalString(contentValue.choice);
if (!actionability || !ACTIONABILITY_VALUES.has(actionability)) return null;

return {
id,
type: "actionability_judgment",
created_at: optionalString(value.created_at) ?? new Date(0).toISOString(),
content: {
explanation: optionalString(contentValue.explanation) ?? "",
actionability:
actionability as ActionabilityJudgmentArtefact["content"]["actionability"],
already_addressed:
typeof contentValue.already_addressed === "boolean"
? contentValue.already_addressed
: false,
},
};
}

function normalizeSignalFindingArtefact(
value: Record<string, unknown>,
): SignalFindingArtefact | null {
const id = optionalString(value.id);
if (!id) return null;

const contentValue = isObjectRecord(value.content) ? value.content : null;
if (!contentValue) return null;

const signalId = optionalString(contentValue.signal_id);
if (!signalId) return null;

return {
id,
type: "signal_finding",
created_at: optionalString(value.created_at) ?? new Date(0).toISOString(),
content: {
signal_id: signalId,
relevant_code_paths: Array.isArray(contentValue.relevant_code_paths)
? contentValue.relevant_code_paths.filter(
(p: unknown): p is string => typeof p === "string",
)
: [],
relevant_commit_hashes: isObjectRecord(
contentValue.relevant_commit_hashes,
)
? Object.fromEntries(
Object.entries(contentValue.relevant_commit_hashes).filter(
(e): e is [string, string] => typeof e[1] === "string",
),
)
: {},
data_queried: optionalString(contentValue.data_queried) ?? "",
verified:
typeof contentValue.verified === "boolean"
? contentValue.verified
: false,
},
};
}

function normalizeSignalReportArtefact(
value: unknown,
): SignalReportArtefact | null {
):
| SignalReportArtefact
| PriorityJudgmentArtefact
| ActionabilityJudgmentArtefact
| SignalFindingArtefact
| null {
if (!isObjectRecord(value)) {
return null;
}

const type = optionalString(value.type);
if (type === "signal_finding") {
return normalizeSignalFindingArtefact(value);
}
if (type === "actionability_judgment") {
return normalizeActionabilityJudgmentArtefact(value);
}
if (type === "priority_judgment") {
return normalizePriorityJudgmentArtefact(value);
}

const id = optionalString(value.id);
if (!id) {
return null;
Expand Down Expand Up @@ -115,7 +247,15 @@ function parseSignalReportArtefactsPayload(

const results = rawResults
.map(normalizeSignalReportArtefact)
.filter((artefact): artefact is SignalReportArtefact => artefact !== null);
.filter(
(
artefact,
): artefact is
| SignalReportArtefact
| PriorityJudgmentArtefact
| ActionabilityJudgmentArtefact
| SignalFindingArtefact => artefact !== null,
);
const count =
typeof payload?.count === "number" ? payload.count : results.length;

Expand Down Expand Up @@ -223,17 +363,8 @@ export class PostHogAPIClient {
async createSignalSourceConfig(
projectId: number,
options: {
source_product:
| "session_replay"
| "llm_analytics"
| "github"
| "linear"
| "zendesk";
source_type:
| "session_analysis_cluster"
| "evaluation"
| "issue"
| "ticket";
source_product: SignalSourceConfig["source_product"];
source_type: SignalSourceConfig["source_type"];
enabled: boolean;
config?: Record<string, unknown>;
},
Expand Down Expand Up @@ -287,6 +418,34 @@ export class PostHogAPIClient {
return (await response.json()) as SignalSourceConfig;
}

async listEvaluations(projectId: number): Promise<Evaluation[]> {
const data = await this.api.get(
"/api/environments/{project_id}/evaluations/",
{
path: { project_id: projectId.toString() },
query: { limit: 200 },
},
);
return data.results ?? [];
}

async updateEvaluation(
projectId: number,
evaluationId: string,
updates: { enabled: boolean },
): Promise<Evaluation> {
return await this.api.patch(
"/api/environments/{project_id}/evaluations/{id}/",
{
path: {
project_id: projectId.toString(),
id: evaluationId,
},
body: updates,
},
);
}

async listExternalDataSources(
projectId: number,
): Promise<ExternalDataSource[]> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,41 @@ interface SetupFormProps {
onCancel: () => void;
}

const POLL_INTERVAL_GITHUB_MS = 3_000;
const POLL_TIMEOUT_GITHUB_MS = 300_000;

function GitHubSetup({ onComplete, onCancel }: SetupFormProps) {
const projectId = useAuthStateValue((state) => state.projectId);
const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
const client = useAuthenticatedClient();
const { githubIntegration, repositories, isLoadingRepos } =
useRepositoryIntegration();
const [repo, setRepo] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [connecting, setConnecting] = useState(false);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
}
}, []);

useEffect(() => stopPolling, [stopPolling]);

// Stop polling once integration appears
useEffect(() => {
if (githubIntegration && connecting) {
stopPolling();
setConnecting(false);
}
}, [githubIntegration, connecting, stopPolling]);

// Auto-select the first repo once loaded
useEffect(() => {
Expand All @@ -67,6 +95,47 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) {
}
}, [repo, repositories]);

const handleConnectGitHub = useCallback(async () => {
if (!cloudRegion || !projectId) return;
setConnecting(true);
try {
await trpcClient.githubIntegration.startFlow.mutate({
region: cloudRegion,
projectId,
});

pollTimerRef.current = setInterval(async () => {
try {
if (!client) return;
// Trigger a refetch of integrations
const integrations =
await client.getIntegrationsForProject(projectId);
const hasGithub = integrations.some(
(i: { kind: string }) => i.kind === "github",
);
if (hasGithub) {
stopPolling();
setConnecting(false);
toast.success("GitHub connected");
}
} catch {
// Ignore individual poll failures
}
}, POLL_INTERVAL_GITHUB_MS);

pollTimeoutRef.current = setTimeout(() => {
stopPolling();
setConnecting(false);
toast.error("Connection timed out. Please try again.");
}, POLL_TIMEOUT_GITHUB_MS);
} catch (error) {
setConnecting(false);
toast.error(
error instanceof Error ? error.message : "Failed to start GitHub flow",
);
}
}, [cloudRegion, projectId, client, stopPolling]);

const handleSubmit = useCallback(async () => {
if (!projectId || !client || !repo || !githubIntegration) return;

Expand Down Expand Up @@ -97,10 +166,28 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) {
if (!githubIntegration) {
return (
<SetupFormContainer title="Connect GitHub">
<Text size="2" style={{ color: "var(--gray-11)" }}>
No GitHub integration found. Please connect GitHub during onboarding
first.
</Text>
<Flex direction="column" gap="3">
<Text size="2" style={{ color: "var(--gray-11)" }}>
Connect your GitHub account to import issues as signals.
</Text>
<Flex gap="2" justify="end">
<Button
size="2"
variant="soft"
onClick={onCancel}
disabled={connecting}
>
Cancel
</Button>
<Button
size="2"
onClick={() => void handleConnectGitHub()}
disabled={connecting}
>
{connecting ? "Waiting for authorization..." : "Connect GitHub"}
</Button>
</Flex>
</Flex>
</SetupFormContainer>
);
}
Expand Down
Loading
Loading