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
5 changes: 5 additions & 0 deletions apps/code/src/main/services/handoff/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ const handoffApiInput = handoffBaseInput.extend({
teamId: z.number(),
});

export const handoffErrorCodeSchema = z.enum(["github_authorization_required"]);

export type HandoffErrorCode = z.infer<typeof handoffErrorCodeSchema>;

const handoffBaseResult = z.object({
success: z.boolean(),
error: z.string().optional(),
code: handoffErrorCodeSchema.optional(),
});

export const handoffPreflightInput = handoffApiInput;
Expand Down
19 changes: 18 additions & 1 deletion apps/code/src/main/services/handoff/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ vi.mock("@main/di/tokens", () => ({
}));

import type { HandoffPreflightInput } from "./schemas";
import { HandoffService } from "./service";
import { extractHandoffErrorCode, HandoffService } from "./service";

const DEFAULT_LOCAL_GIT_STATE = {
head: "abc123",
Expand Down Expand Up @@ -172,3 +172,20 @@ describe("HandoffService.preflight", () => {
expect(result.localTreeDirty).toBe(false);
});
});

describe("extractHandoffErrorCode", () => {
it("detects GitHub authorization failures in backend error payloads", () => {
const message =
'Failed request: [400] {"type":"validation_error","code":"github_authorization_required","detail":"Link a GitHub account"}';

expect(extractHandoffErrorCode(message)).toBe(
"github_authorization_required",
);
});

it("ignores unrelated failures", () => {
expect(extractHandoffErrorCode("Failed request: [500] boom")).toBe(
undefined,
);
});
});
20 changes: 19 additions & 1 deletion apps/code/src/main/services/handoff/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type HandoffToCloudSagaDeps,
} from "./handoff-to-cloud-saga";
import {
type HandoffErrorCode,
HandoffEvent,
type HandoffExecuteInput,
type HandoffExecuteResult,
Expand All @@ -49,6 +50,18 @@ import {

const log = logger.scope("handoff");
const CONTINUE_DIVERGENCE_BUTTON = 1;
const GITHUB_AUTHORIZATION_REQUIRED_CODE = "github_authorization_required";
const GITHUB_AUTHORIZATION_REQUIRED_MESSAGE =
"Connect GitHub in your browser, then retry Continue in cloud.";

export function extractHandoffErrorCode(
message: string | undefined,
): HandoffErrorCode | undefined {
if (message?.includes(GITHUB_AUTHORIZATION_REQUIRED_CODE)) {
return GITHUB_AUTHORIZATION_REQUIRED_CODE;
}
return undefined;
}

@injectable()
export class HandoffService extends TypedEventEmitter<HandoffServiceEvents> {
Expand Down Expand Up @@ -350,9 +363,14 @@ export class HandoffService extends TypedEventEmitter<HandoffServiceEvents> {
failedStep: result.failedStep,
});
deps.onProgress("failed", result.error ?? "Handoff to cloud failed");
const code = extractHandoffErrorCode(result.error);
return {
success: false,
error: `Handoff to cloud failed at step '${result.failedStep}': ${result.error}`,
code,
error:
code === GITHUB_AUTHORIZATION_REQUIRED_CODE
? GITHUB_AUTHORIZATION_REQUIRED_MESSAGE
: `Handoff to cloud failed at step '${result.failedStep}': ${result.error}`,
};
}

Expand Down
166 changes: 158 additions & 8 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ export type McpInstallationTool = Schemas.MCPServerInstallationTool;

export type Evaluation = Schemas.Evaluation;

export interface UserGitHubIntegration {
id: string;
kind: "github";
installation_id: string;
repository_selection?: string | null;
account?: {
type?: string | null;
name?: string | null;
} | null;
uses_shared_installation?: boolean;
created_at?: string;
}

export interface SignalSourceConfig {
id: string;
source_product:
Expand Down Expand Up @@ -153,7 +166,6 @@ interface CloudRunOptions {
prAuthorshipMode?: PrAuthorshipMode;
runSource?: CloudRunSource;
signalReportId?: string;
githubUserToken?: string;
initialPermissionMode?: PermissionMode;
}

Expand Down Expand Up @@ -230,9 +242,6 @@ function buildCloudRunRequestBody(
if (options?.signalReportId) {
body.signal_report_id = options.signalReportId;
}
if (options?.githubUserToken) {
body.github_user_token = options.githubUserToken;
}
if (options?.initialPermissionMode) {
body.initial_permission_mode = options.initialPermissionMode;
}
Expand Down Expand Up @@ -563,7 +572,7 @@ export class PostHogAPIClient {
*/
async startGithubUserIntegrationConnect(teamId?: number): Promise<{
install_url: string;
connect_flow?: "oauth_authorize" | "app_install";
connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install";
}> {
const id = teamId ?? (await this.getTeamId());
const urlPath = `/api/users/@me/integrations/github/start/`;
Expand All @@ -588,10 +597,31 @@ export class PostHogAPIClient {
}
return (await response.json()) as {
install_url: string;
connect_flow?: "oauth_authorize" | "app_install";
connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install";
};
}

async getGithubUserIntegrations(): Promise<UserGitHubIntegration[]> {
const urlPath = `/api/users/@me/integrations/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});

if (!response.ok) {
throw new Error(
`Failed to fetch personal GitHub integrations: ${response.statusText}`,
);
}

const data = (await response.json()) as {
results?: UserGitHubIntegration[];
};
return data.results ?? [];
}

async switchOrganization(orgId: string): Promise<void> {
await this.api.patch("/api/users/{uuid}/", {
path: { uuid: "@me" },
Expand Down Expand Up @@ -837,17 +867,19 @@ export class PostHogAPIClient {
>
> & {
github_integration?: number | null;
github_user_integration?: string | null;
/** POST-only: `SignalReportTask.relationship` to create when linking to `signal_report`. */
signal_report_task_relationship?: SignalReportTaskRelationship;
},
) {
const teamId = await this.getTeamId();
const { origin_product: originProduct, ...taskOptions } = options;

const data = await this.api.post(`/api/projects/{project_id}/tasks/`, {
path: { project_id: teamId.toString() },
body: {
origin_product: "user_created",
...options,
...taskOptions,
origin_product: originProduct ?? "user_created",
} as unknown as Schemas.Task,
});

Expand Down Expand Up @@ -883,6 +915,7 @@ export class PostHogAPIClient {
json_schema: task.json_schema,
origin_product: task.origin_product,
github_integration: task.github_integration,
github_user_integration: task.github_user_integration,
});
}

Expand Down Expand Up @@ -1438,6 +1471,45 @@ export class PostHogAPIClient {
};
}

async getGithubUserBranchesPage(
installationId: string | number,
repo: string,
offset: number,
limit: number,
search?: string,
): Promise<{
branches: string[];
defaultBranch: string | null;
hasMore: boolean;
}> {
const urlPath = `/api/users/@me/integrations/github/${installationId}/branches/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
url.searchParams.set("repo", repo);
url.searchParams.set("offset", String(offset));
url.searchParams.set("limit", String(limit));
if (search?.trim()) {
url.searchParams.set("search", search.trim());
}
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});

if (!response.ok) {
throw new Error(
`Failed to fetch personal GitHub branches: ${response.statusText}`,
);
}

const data = await response.json();
return {
branches: data.branches ?? data.results ?? data ?? [],
defaultBranch: data.default_branch ?? null,
hasMore: data.has_more ?? false,
};
}

async getGithubRepositories(
integrationId: string | number,
): Promise<string[]> {
Expand Down Expand Up @@ -1497,6 +1569,63 @@ export class PostHogAPIClient {
};
}

async getGithubUserRepositories(
installationId: string | number,
): Promise<string[]> {
const repositories: string[] = [];
let offset = 0;

while (true) {
const page = await this.getGithubUserRepositoriesPage(
installationId,
offset,
500,
);
repositories.push(...page.repositories);

if (!page.hasMore) {
return repositories;
}

offset += page.repositories.length;
}
}

async getGithubUserRepositoriesPage(
installationId: string | number,
offset: number,
limit: number,
search?: string,
): Promise<{
repositories: string[];
hasMore: boolean;
}> {
const urlPath = `/api/users/@me/integrations/github/${installationId}/repos/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
url.searchParams.set("offset", String(offset));
url.searchParams.set("limit", String(limit));
if (search?.trim()) {
url.searchParams.set("search", search.trim());
}
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});

if (!response.ok) {
throw new Error(
`Failed to fetch personal GitHub repositories: ${response.statusText}`,
);
}

const data = await response.json();
return {
repositories: this.normalizeGithubRepositories(data),
hasMore: data.has_more ?? false,
};
}

async refreshGithubRepositories(
integrationId: string | number,
): Promise<string[]> {
Expand All @@ -1520,6 +1649,27 @@ export class PostHogAPIClient {
return this.normalizeGithubRepositories(data);
}

async refreshGithubUserRepositories(
installationId: string | number,
): Promise<string[]> {
const urlPath = `/api/users/@me/integrations/github/${installationId}/repos/refresh/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "post",
url,
path: urlPath,
});

if (!response.ok) {
throw new Error(
`Failed to refresh personal GitHub repositories: ${response.statusText}`,
);
}

const data = await response.json();
return this.normalizeGithubRepositories(data);
}

private normalizeGithubRepositories(data: unknown): string[] {
const repos =
(data as { repositories?: unknown[] }).repositories ??
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Button } from "@components/ui/Button";
import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient";
import { useAuthStateValue } from "@features/auth/hooks/authQueries";
import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery";
import { useRepositoryIntegration } from "@hooks/useIntegrations";
import { useUserRepositoryIntegration } from "@hooks/useIntegrations";
import {
ArrowSquareOutIcon,
GithubLogoIcon,
Expand All @@ -22,15 +22,14 @@ async function openUrlInBrowser(url: string): Promise<void> {
}
}

/** Uses project-scoped integrations (see useRepositoryIntegration), not session `current_team`. */
export function GitHubConnectionBanner() {
const { data: githubLogin, isLoading: loginLoading } = useAuthenticatedQuery(
["github_login"],
async (client) => client.getGithubLogin(),
{ staleTime: 5 * 60 * 1000 },
);
const { hasGithubIntegration: hasGithubForProject } =
useRepositoryIntegration();
useUserRepositoryIntegration();
const apiClient = useOptionalAuthenticatedClient();
const projectId = useAuthStateValue((s) => s.projectId);
const cloudRegion = useAuthStateValue((s) => s.cloudRegion);
Expand All @@ -50,6 +49,9 @@ export function GitHubConnectionBanner() {
void queryClient.invalidateQueries({
queryKey: ["integrations", "list"],
});
void queryClient.invalidateQueries({
queryKey: ["user-github-integrations"],
});
}
};
window.addEventListener("focus", onFocus);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const useInboxCloudTaskStore = create<InboxCloudTaskStore>()(
workspaceMode: "cloud",
githubIntegrationId: params.githubIntegrationId,
repository: selectedRepo,
cloudPrAuthorshipMode: "user",
cloudPrAuthorshipMode: "bot",
cloudRunSource: "signal_report",
signalReportId: params.reportId,
});
Expand Down
Loading
Loading