Skip to content

Commit c127528

Browse files
authored
feat(code): use user GitHub integrations for cloud tasks (#1951)
1 parent bae960e commit c127528

16 files changed

Lines changed: 820 additions & 89 deletions

File tree

apps/code/src/main/services/handoff/schemas.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ const handoffApiInput = handoffBaseInput.extend({
1414
teamId: z.number(),
1515
});
1616

17+
export const handoffErrorCodeSchema = z.enum(["github_authorization_required"]);
18+
19+
export type HandoffErrorCode = z.infer<typeof handoffErrorCodeSchema>;
20+
1721
const handoffBaseResult = z.object({
1822
success: z.boolean(),
1923
error: z.string().optional(),
24+
code: handoffErrorCodeSchema.optional(),
2025
});
2126

2227
export const handoffPreflightInput = handoffApiInput;

apps/code/src/main/services/handoff/service.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ vi.mock("@main/di/tokens", () => ({
6565
}));
6666

6767
import type { HandoffPreflightInput } from "./schemas";
68-
import { HandoffService } from "./service";
68+
import { extractHandoffErrorCode, HandoffService } from "./service";
6969

7070
const DEFAULT_LOCAL_GIT_STATE = {
7171
head: "abc123",
@@ -172,3 +172,20 @@ describe("HandoffService.preflight", () => {
172172
expect(result.localTreeDirty).toBe(false);
173173
});
174174
});
175+
176+
describe("extractHandoffErrorCode", () => {
177+
it("detects GitHub authorization failures in backend error payloads", () => {
178+
const message =
179+
'Failed request: [400] {"type":"validation_error","code":"github_authorization_required","detail":"Link a GitHub account"}';
180+
181+
expect(extractHandoffErrorCode(message)).toBe(
182+
"github_authorization_required",
183+
);
184+
});
185+
186+
it("ignores unrelated failures", () => {
187+
expect(extractHandoffErrorCode("Failed request: [500] boom")).toBe(
188+
undefined,
189+
);
190+
});
191+
});

apps/code/src/main/services/handoff/service.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
type HandoffToCloudSagaDeps,
3636
} from "./handoff-to-cloud-saga";
3737
import {
38+
type HandoffErrorCode,
3839
HandoffEvent,
3940
type HandoffExecuteInput,
4041
type HandoffExecuteResult,
@@ -49,6 +50,18 @@ import {
4950

5051
const log = logger.scope("handoff");
5152
const CONTINUE_DIVERGENCE_BUTTON = 1;
53+
const GITHUB_AUTHORIZATION_REQUIRED_CODE = "github_authorization_required";
54+
const GITHUB_AUTHORIZATION_REQUIRED_MESSAGE =
55+
"Connect GitHub in your browser, then retry Continue in cloud.";
56+
57+
export function extractHandoffErrorCode(
58+
message: string | undefined,
59+
): HandoffErrorCode | undefined {
60+
if (message?.includes(GITHUB_AUTHORIZATION_REQUIRED_CODE)) {
61+
return GITHUB_AUTHORIZATION_REQUIRED_CODE;
62+
}
63+
return undefined;
64+
}
5265

5366
@injectable()
5467
export class HandoffService extends TypedEventEmitter<HandoffServiceEvents> {
@@ -350,9 +363,14 @@ export class HandoffService extends TypedEventEmitter<HandoffServiceEvents> {
350363
failedStep: result.failedStep,
351364
});
352365
deps.onProgress("failed", result.error ?? "Handoff to cloud failed");
366+
const code = extractHandoffErrorCode(result.error);
353367
return {
354368
success: false,
355-
error: `Handoff to cloud failed at step '${result.failedStep}': ${result.error}`,
369+
code,
370+
error:
371+
code === GITHUB_AUTHORIZATION_REQUIRED_CODE
372+
? GITHUB_AUTHORIZATION_REQUIRED_MESSAGE
373+
: `Handoff to cloud failed at step '${result.failedStep}': ${result.error}`,
356374
};
357375
}
358376

apps/code/src/renderer/api/posthogClient.ts

Lines changed: 158 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ export type McpInstallationTool = Schemas.MCPServerInstallationTool;
7070

7171
export type Evaluation = Schemas.Evaluation;
7272

73+
export interface UserGitHubIntegration {
74+
id: string;
75+
kind: "github";
76+
installation_id: string;
77+
repository_selection?: string | null;
78+
account?: {
79+
type?: string | null;
80+
name?: string | null;
81+
} | null;
82+
uses_shared_installation?: boolean;
83+
created_at?: string;
84+
}
85+
7386
export interface SignalSourceConfig {
7487
id: string;
7588
source_product:
@@ -153,7 +166,6 @@ interface CloudRunOptions {
153166
prAuthorshipMode?: PrAuthorshipMode;
154167
runSource?: CloudRunSource;
155168
signalReportId?: string;
156-
githubUserToken?: string;
157169
initialPermissionMode?: PermissionMode;
158170
}
159171

@@ -230,9 +242,6 @@ function buildCloudRunRequestBody(
230242
if (options?.signalReportId) {
231243
body.signal_report_id = options.signalReportId;
232244
}
233-
if (options?.githubUserToken) {
234-
body.github_user_token = options.githubUserToken;
235-
}
236245
if (options?.initialPermissionMode) {
237246
body.initial_permission_mode = options.initialPermissionMode;
238247
}
@@ -563,7 +572,7 @@ export class PostHogAPIClient {
563572
*/
564573
async startGithubUserIntegrationConnect(teamId?: number): Promise<{
565574
install_url: string;
566-
connect_flow?: "oauth_authorize" | "app_install";
575+
connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install";
567576
}> {
568577
const id = teamId ?? (await this.getTeamId());
569578
const urlPath = `/api/users/@me/integrations/github/start/`;
@@ -588,10 +597,31 @@ export class PostHogAPIClient {
588597
}
589598
return (await response.json()) as {
590599
install_url: string;
591-
connect_flow?: "oauth_authorize" | "app_install";
600+
connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install";
592601
};
593602
}
594603

604+
async getGithubUserIntegrations(): Promise<UserGitHubIntegration[]> {
605+
const urlPath = `/api/users/@me/integrations/`;
606+
const url = new URL(`${this.api.baseUrl}${urlPath}`);
607+
const response = await this.api.fetcher.fetch({
608+
method: "get",
609+
url,
610+
path: urlPath,
611+
});
612+
613+
if (!response.ok) {
614+
throw new Error(
615+
`Failed to fetch personal GitHub integrations: ${response.statusText}`,
616+
);
617+
}
618+
619+
const data = (await response.json()) as {
620+
results?: UserGitHubIntegration[];
621+
};
622+
return data.results ?? [];
623+
}
624+
595625
async switchOrganization(orgId: string): Promise<void> {
596626
await this.api.patch("/api/users/{uuid}/", {
597627
path: { uuid: "@me" },
@@ -837,17 +867,19 @@ export class PostHogAPIClient {
837867
>
838868
> & {
839869
github_integration?: number | null;
870+
github_user_integration?: string | null;
840871
/** POST-only: `SignalReportTask.relationship` to create when linking to `signal_report`. */
841872
signal_report_task_relationship?: SignalReportTaskRelationship;
842873
},
843874
) {
844875
const teamId = await this.getTeamId();
876+
const { origin_product: originProduct, ...taskOptions } = options;
845877

846878
const data = await this.api.post(`/api/projects/{project_id}/tasks/`, {
847879
path: { project_id: teamId.toString() },
848880
body: {
849-
origin_product: "user_created",
850-
...options,
881+
...taskOptions,
882+
origin_product: originProduct ?? "user_created",
851883
} as unknown as Schemas.Task,
852884
});
853885

@@ -883,6 +915,7 @@ export class PostHogAPIClient {
883915
json_schema: task.json_schema,
884916
origin_product: task.origin_product,
885917
github_integration: task.github_integration,
918+
github_user_integration: task.github_user_integration,
886919
});
887920
}
888921

@@ -1438,6 +1471,45 @@ export class PostHogAPIClient {
14381471
};
14391472
}
14401473

1474+
async getGithubUserBranchesPage(
1475+
installationId: string | number,
1476+
repo: string,
1477+
offset: number,
1478+
limit: number,
1479+
search?: string,
1480+
): Promise<{
1481+
branches: string[];
1482+
defaultBranch: string | null;
1483+
hasMore: boolean;
1484+
}> {
1485+
const urlPath = `/api/users/@me/integrations/github/${installationId}/branches/`;
1486+
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1487+
url.searchParams.set("repo", repo);
1488+
url.searchParams.set("offset", String(offset));
1489+
url.searchParams.set("limit", String(limit));
1490+
if (search?.trim()) {
1491+
url.searchParams.set("search", search.trim());
1492+
}
1493+
const response = await this.api.fetcher.fetch({
1494+
method: "get",
1495+
url,
1496+
path: urlPath,
1497+
});
1498+
1499+
if (!response.ok) {
1500+
throw new Error(
1501+
`Failed to fetch personal GitHub branches: ${response.statusText}`,
1502+
);
1503+
}
1504+
1505+
const data = await response.json();
1506+
return {
1507+
branches: data.branches ?? data.results ?? data ?? [],
1508+
defaultBranch: data.default_branch ?? null,
1509+
hasMore: data.has_more ?? false,
1510+
};
1511+
}
1512+
14411513
async getGithubRepositories(
14421514
integrationId: string | number,
14431515
): Promise<string[]> {
@@ -1497,6 +1569,63 @@ export class PostHogAPIClient {
14971569
};
14981570
}
14991571

1572+
async getGithubUserRepositories(
1573+
installationId: string | number,
1574+
): Promise<string[]> {
1575+
const repositories: string[] = [];
1576+
let offset = 0;
1577+
1578+
while (true) {
1579+
const page = await this.getGithubUserRepositoriesPage(
1580+
installationId,
1581+
offset,
1582+
500,
1583+
);
1584+
repositories.push(...page.repositories);
1585+
1586+
if (!page.hasMore) {
1587+
return repositories;
1588+
}
1589+
1590+
offset += page.repositories.length;
1591+
}
1592+
}
1593+
1594+
async getGithubUserRepositoriesPage(
1595+
installationId: string | number,
1596+
offset: number,
1597+
limit: number,
1598+
search?: string,
1599+
): Promise<{
1600+
repositories: string[];
1601+
hasMore: boolean;
1602+
}> {
1603+
const urlPath = `/api/users/@me/integrations/github/${installationId}/repos/`;
1604+
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1605+
url.searchParams.set("offset", String(offset));
1606+
url.searchParams.set("limit", String(limit));
1607+
if (search?.trim()) {
1608+
url.searchParams.set("search", search.trim());
1609+
}
1610+
const response = await this.api.fetcher.fetch({
1611+
method: "get",
1612+
url,
1613+
path: urlPath,
1614+
});
1615+
1616+
if (!response.ok) {
1617+
throw new Error(
1618+
`Failed to fetch personal GitHub repositories: ${response.statusText}`,
1619+
);
1620+
}
1621+
1622+
const data = await response.json();
1623+
return {
1624+
repositories: this.normalizeGithubRepositories(data),
1625+
hasMore: data.has_more ?? false,
1626+
};
1627+
}
1628+
15001629
async refreshGithubRepositories(
15011630
integrationId: string | number,
15021631
): Promise<string[]> {
@@ -1520,6 +1649,27 @@ export class PostHogAPIClient {
15201649
return this.normalizeGithubRepositories(data);
15211650
}
15221651

1652+
async refreshGithubUserRepositories(
1653+
installationId: string | number,
1654+
): Promise<string[]> {
1655+
const urlPath = `/api/users/@me/integrations/github/${installationId}/repos/refresh/`;
1656+
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1657+
const response = await this.api.fetcher.fetch({
1658+
method: "post",
1659+
url,
1660+
path: urlPath,
1661+
});
1662+
1663+
if (!response.ok) {
1664+
throw new Error(
1665+
`Failed to refresh personal GitHub repositories: ${response.statusText}`,
1666+
);
1667+
}
1668+
1669+
const data = await response.json();
1670+
return this.normalizeGithubRepositories(data);
1671+
}
1672+
15231673
private normalizeGithubRepositories(data: unknown): string[] {
15241674
const repos =
15251675
(data as { repositories?: unknown[] }).repositories ??

apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Button } from "@components/ui/Button";
22
import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient";
33
import { useAuthStateValue } from "@features/auth/hooks/authQueries";
44
import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery";
5-
import { useRepositoryIntegration } from "@hooks/useIntegrations";
5+
import { useUserRepositoryIntegration } from "@hooks/useIntegrations";
66
import {
77
ArrowSquareOutIcon,
88
GithubLogoIcon,
@@ -22,15 +22,14 @@ async function openUrlInBrowser(url: string): Promise<void> {
2222
}
2323
}
2424

25-
/** Uses project-scoped integrations (see useRepositoryIntegration), not session `current_team`. */
2625
export function GitHubConnectionBanner() {
2726
const { data: githubLogin, isLoading: loginLoading } = useAuthenticatedQuery(
2827
["github_login"],
2928
async (client) => client.getGithubLogin(),
3029
{ staleTime: 5 * 60 * 1000 },
3130
);
3231
const { hasGithubIntegration: hasGithubForProject } =
33-
useRepositoryIntegration();
32+
useUserRepositoryIntegration();
3433
const apiClient = useOptionalAuthenticatedClient();
3534
const projectId = useAuthStateValue((s) => s.projectId);
3635
const cloudRegion = useAuthStateValue((s) => s.cloudRegion);
@@ -50,6 +49,9 @@ export function GitHubConnectionBanner() {
5049
void queryClient.invalidateQueries({
5150
queryKey: ["integrations", "list"],
5251
});
52+
void queryClient.invalidateQueries({
53+
queryKey: ["user-github-integrations"],
54+
});
5355
}
5456
};
5557
window.addEventListener("focus", onFocus);

apps/code/src/renderer/features/inbox/stores/inboxCloudTaskStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export const useInboxCloudTaskStore = create<InboxCloudTaskStore>()(
6161
workspaceMode: "cloud",
6262
githubIntegrationId: params.githubIntegrationId,
6363
repository: selectedRepo,
64-
cloudPrAuthorshipMode: "user",
64+
cloudPrAuthorshipMode: "bot",
6565
cloudRunSource: "signal_report",
6666
signalReportId: params.reportId,
6767
});

0 commit comments

Comments
 (0)