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
83 changes: 64 additions & 19 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
SignalReportSignalsResponse,
SignalReportsQueryParams,
SignalReportsResponse,
SuggestedReviewersArtefact,
Task,
TaskRun,
} from "@shared/types";
Expand All @@ -20,15 +21,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,9 +71,9 @@ function optionalString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}

function normalizeSignalReportArtefact(
value: unknown,
): SignalReportArtefact | null {
type AnyArtefact = SignalReportArtefact | SuggestedReviewersArtefact;

function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null {
if (!isObjectRecord(value)) {
return null;
}
Expand All @@ -72,6 +83,21 @@ function normalizeSignalReportArtefact(
return null;
}

const type = optionalString(value.type) ?? "unknown";
const created_at =
optionalString(value.created_at) ?? new Date(0).toISOString();

// suggested_reviewers: content is an array of reviewer objects
if (type === "suggested_reviewers" && Array.isArray(value.content)) {
return {
id,
type: "suggested_reviewers" as const,
created_at,
content: value.content as SuggestedReviewersArtefact["content"],
};
}

// video_segment and other artefacts with object content
const contentValue = isObjectRecord(value.content) ? value.content : null;
if (!contentValue) {
return null;
Expand All @@ -87,8 +113,8 @@ function normalizeSignalReportArtefact(

return {
id,
type: optionalString(value.type) ?? "unknown",
created_at: optionalString(value.created_at) ?? new Date(0).toISOString(),
type,
created_at,
content: {
session_id: sessionId ?? "",
start_time: optionalString(contentValue.start_time) ?? "",
Expand All @@ -115,7 +141,7 @@ function parseSignalReportArtefactsPayload(

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

Expand Down Expand Up @@ -223,17 +249,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 +304,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
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
} from "@radix-ui/themes";
import { getCloudUrlFromRegion } from "@shared/constants/oauth";
import type {
SignalReportArtefact,
SignalReportArtefactsResponse,
SignalReportsQueryParams,
} from "@shared/types";
Expand Down Expand Up @@ -201,7 +202,9 @@ export function InboxSignalsTab() {
const artefactsQuery = useInboxReportArtefacts(selectedReport?.id ?? "", {
enabled: !!selectedReport,
});
const visibleArtefacts = artefactsQuery.data?.results ?? [];
const visibleArtefacts = (artefactsQuery.data?.results ?? []).filter(
(a): a is SignalReportArtefact => a.type === "video_segment",
);
const artefactsUnavailableReason = artefactsQuery.data?.unavailableReason;
const showArtefactsUnavailable =
!artefactsQuery.isLoading &&
Expand Down Expand Up @@ -543,7 +546,7 @@ export function InboxSignalsTab() {
!showArtefactsUnavailable &&
visibleArtefacts.length === 0 && (
<Text size="1" color="gray" className="block text-[12px]">
No artefacts were returned for this signal.
No session segments available for this report.
</Text>
)}

Expand Down
Loading
Loading