From 05a97d31c48a82fb286af9cfac3bc8904cde2b20 Mon Sep 17 00:00:00 2001 From: Will Chen Date: Mon, 14 Apr 2025 21:55:51 -0700 Subject: [PATCH] github repo creation flow --- ...es.sql => 0000_nebulous_proemial_gods.sql} | 4 +- drizzle/meta/0000_snapshot.json | 16 ++- drizzle/meta/_journal.json | 4 +- src/components/GitHubConnector.tsx | 126 +++++++++++++++++- src/db/index.ts | 12 ++ src/db/schema.ts | 2 + src/ipc/handlers/github_handlers.ts | 92 ++++++++++++- src/ipc/ipc_client.ts | 34 +++++ src/ipc/ipc_types.ts | 2 + src/pages/app-details.tsx | 2 +- src/paths/paths.ts | 2 +- src/preload.ts | 2 + 12 files changed, 288 insertions(+), 10 deletions(-) rename drizzle/{0000_mighty_stark_industries.sql => 0000_nebulous_proemial_gods.sql} (89%) diff --git a/drizzle/0000_mighty_stark_industries.sql b/drizzle/0000_nebulous_proemial_gods.sql similarity index 89% rename from drizzle/0000_mighty_stark_industries.sql rename to drizzle/0000_nebulous_proemial_gods.sql index c74abda..ec78439 100644 --- a/drizzle/0000_mighty_stark_industries.sql +++ b/drizzle/0000_nebulous_proemial_gods.sql @@ -3,7 +3,9 @@ CREATE TABLE `apps` ( `name` text NOT NULL, `path` text NOT NULL, `created_at` integer DEFAULT (unixepoch()) NOT NULL, - `updated_at` integer DEFAULT (unixepoch()) NOT NULL + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + `github_org` text, + `github_repo` text ); --> statement-breakpoint CREATE TABLE `chats` ( diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index f6fb5c8..9de7b06 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "8551dcbc-91d8-4b82-afc4-9c4b6684de3a", + "id": "1a0ffcb3-606d-4b03-81b7-7c585555a548", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "apps": { @@ -43,6 +43,20 @@ "notNull": true, "autoincrement": false, "default": "(unixepoch())" + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false } }, "indexes": {}, diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ebe17c2..796a6ca 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1744233051865, - "tag": "0000_mighty_stark_industries", + "when": 1744692127560, + "tag": "0000_nebulous_proemial_gods", "breakpoints": true } ] diff --git a/src/components/GitHubConnector.tsx b/src/components/GitHubConnector.tsx index 6077a14..c1ee2ad 100644 --- a/src/components/GitHubConnector.tsx +++ b/src/components/GitHubConnector.tsx @@ -3,13 +3,16 @@ import { Button } from "@/components/ui/button"; import { Github } from "lucide-react"; import { IpcClient } from "@/ipc/ipc_client"; import { useSettings } from "@/hooks/useSettings"; +import { useLoadApp } from "@/hooks/useLoadApp"; interface GitHubConnectorProps { appId: number | null; + folderName: string; } -export function GitHubConnector({ appId }: GitHubConnectorProps) { +export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) { // --- GitHub Device Flow State --- + const { app, refreshApp } = useLoadApp(appId); const { settings, refreshSettings } = useSettings(); const [githubUserCode, setGithubUserCode] = useState(null); const [githubVerificationUri, setGithubVerificationUri] = useState< @@ -106,10 +109,127 @@ export function GitHubConnector({ appId }: GitHubConnectorProps) { }; }, [appId]); // Re-run effect if appId changes + // --- Create Repo State --- + const [repoName, setRepoName] = useState(folderName); + const [repoAvailable, setRepoAvailable] = useState(null); + const [repoCheckError, setRepoCheckError] = useState(null); + const [isCheckingRepo, setIsCheckingRepo] = useState(false); + const [isCreatingRepo, setIsCreatingRepo] = useState(false); + const [createRepoError, setCreateRepoError] = useState(null); + const [createRepoSuccess, setCreateRepoSuccess] = useState(false); + + // Assume org is the authenticated user for now (could add org input later) + // TODO: After device flow, fetch and store the GitHub username/org in settings for use here + const githubOrg = ""; // Use empty string for now (GitHub API will default to the authenticated user) + + const handleRepoNameBlur = async () => { + setRepoCheckError(null); + setRepoAvailable(null); + if (!repoName) return; + setIsCheckingRepo(true); + try { + const result = await IpcClient.getInstance().checkGithubRepoAvailable( + githubOrg, + repoName + ); + setRepoAvailable(result.available); + if (!result.available) { + setRepoCheckError(result.error || "Repository name is not available."); + } + } catch (err: any) { + setRepoCheckError(err.message || "Failed to check repo availability."); + } finally { + setIsCheckingRepo(false); + } + }; + + const handleCreateRepo = async (e: React.FormEvent) => { + e.preventDefault(); + setCreateRepoError(null); + setIsCreatingRepo(true); + setCreateRepoSuccess(false); + try { + const result = await IpcClient.getInstance().createGithubRepo( + githubOrg, + repoName, + appId! + ); + if (result.success) { + setCreateRepoSuccess(true); + setRepoCheckError(null); + refreshApp(); + } else { + setCreateRepoError(result.error || "Failed to create repository."); + } + } catch (err: any) { + setCreateRepoError(err.message || "Failed to create repository."); + } finally { + setIsCreatingRepo(false); + } + }; + + if (app?.githubOrg && app?.githubRepo) { + return ( + + ); + } + if (settings?.githubSettings.secrets) { return ( -
-

Connected to GitHub!

+
+

Set up your GitHub repo

+
+ + { + setRepoName(e.target.value); + setRepoAvailable(null); + setRepoCheckError(null); + }} + onBlur={handleRepoNameBlur} + disabled={isCreatingRepo} + /> + {isCheckingRepo && ( +

Checking availability...

+ )} + {repoAvailable === true && ( +

+ Repository name is available! +

+ )} + {repoAvailable === false && ( +

{repoCheckError}

+ )} + +
+ {createRepoError && ( +

{createRepoError}

+ )} + {createRepoSuccess && ( +

Repository created and linked!

+ )}
); } diff --git a/src/db/index.ts b/src/db/index.ts index 62b3794..e01f953 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -8,6 +8,7 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import path from "node:path"; import fs from "node:fs"; import { getDyadAppPath, getUserDataPath } from "../paths/paths"; +import { eq } from "drizzle-orm"; // Database connection factory let _db: ReturnType | null = null; @@ -91,3 +92,14 @@ try { export const db = _db as any as BetterSQLite3Database & { $client: Database.Database; }; + +export async function updateAppGithubRepo( + appId: number, + org: string, + repo: string +): Promise { + await db + .update(schema.apps) + .set({ githubOrg: org, githubRepo: repo }) + .where(eq(schema.apps.id, appId)); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 2ec8b03..a435e3f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -12,6 +12,8 @@ export const apps = sqliteTable("apps", { updatedAt: integer("updated_at", { mode: "timestamp" }) .notNull() .default(sql`(unixepoch())`), + githubOrg: text("github_org"), + githubRepo: text("github_repo"), }); export const chats = sqliteTable("chats", { diff --git a/src/ipc/handlers/github_handlers.ts b/src/ipc/handlers/github_handlers.ts index 4b482e5..470c56f 100644 --- a/src/ipc/handlers/github_handlers.ts +++ b/src/ipc/handlers/github_handlers.ts @@ -5,7 +5,8 @@ import { IpcMainInvokeEvent, } from "electron"; import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process -import { writeSettings } from "../../main/settings"; +import { writeSettings, readSettings } from "../../main/settings"; +import { updateAppGithubRepo } from "../../db/index"; // --- GitHub Device Flow Constants --- // TODO: Fetch this securely, e.g., from environment variables or a config file @@ -275,8 +276,97 @@ function handleStartGithubFlow( // event.sender.send('github:flow-cancelled', { message: 'GitHub flow cancelled.' }); // } +// --- GitHub Repo Availability Handler --- +async function handleIsRepoAvailable( + event: IpcMainInvokeEvent, + { org, repo }: { org: string; repo: string } +) { + try { + // Get access token from settings + const settings = readSettings(); + const accessToken = settings.githubSettings?.secrets?.accessToken; + if (!accessToken) { + return { available: false, error: "Not authenticated with GitHub." }; + } + // If org is empty, use the authenticated user + const owner = + org || + (await fetch("https://api.github.com/user", { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then((r) => r.json()) + .then((u) => u.login)); + // Check if repo exists + const url = `https://api.github.com/repos/${owner}/${repo}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (res.status === 404) { + return { available: true }; + } else if (res.ok) { + return { available: false, error: "Repository already exists." }; + } else { + const data = await res.json(); + return { available: false, error: data.message || "Unknown error" }; + } + } catch (err: any) { + return { available: false, error: err.message || "Unknown error" }; + } +} + +// --- GitHub Create Repo Handler --- +async function handleCreateRepo( + event: IpcMainInvokeEvent, + { org, repo, appId }: { org: string; repo: string; appId: number } +) { + try { + // Get access token from settings + const settings = readSettings(); + const accessToken = settings.githubSettings?.secrets?.accessToken; + if (!accessToken) { + return { success: false, error: "Not authenticated with GitHub." }; + } + // If org is empty, create for the authenticated user + let owner = org; + if (!owner) { + const userRes = await fetch("https://api.github.com/user", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const user = await userRes.json(); + owner = user.login; + } + // Create repo + const createUrl = org + ? `https://api.github.com/orgs/${owner}/repos` + : `https://api.github.com/user/repos`; + const res = await fetch(createUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "application/vnd.github+json", + }, + body: JSON.stringify({ + name: repo, + private: true, + }), + }); + if (!res.ok) { + const data = await res.json(); + return { success: false, error: data.message || "Failed to create repo" }; + } + // Store org and repo in the app's DB row (apps table) + await updateAppGithubRepo(appId, owner, repo); + return { success: true }; + } catch (err: any) { + return { success: false, error: err.message || "Unknown error" }; + } +} + // --- Registration --- export function registerGithubHandlers() { ipcMain.handle("github:start-flow", handleStartGithubFlow); // ipcMain.on('github:cancel-flow', handleCancelGithubFlow); // Uncomment if you add cancellation + ipcMain.handle("github:is-repo-available", handleIsRepoAvailable); + ipcMain.handle("github:create-repo", handleCreateRepo); } diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 795c547..a499543 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -571,6 +571,40 @@ export class IpcClient { // } // --- End GitHub Device Flow --- + // --- GitHub Repo Management --- + public async checkGithubRepoAvailable( + org: string, + repo: string + ): Promise<{ available: boolean; error?: string }> { + try { + const result = await this.ipcRenderer.invoke("github:is-repo-available", { + org, + repo, + }); + return result; + } catch (error: any) { + return { available: false, error: error.message || "Unknown error" }; + } + } + + public async createGithubRepo( + org: string, + repo: string, + appId: number + ): Promise<{ success: boolean; error?: string }> { + try { + const result = await this.ipcRenderer.invoke("github:create-repo", { + org, + repo, + appId, + }); + return result; + } catch (error: any) { + return { success: false, error: error.message || "Unknown error" }; + } + } + // --- End GitHub Repo Management --- + // Example methods for listening to events (if needed) // public on(channel: string, func: (...args: any[]) => void): void { } diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index 050f1cd..e24f26c 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -50,6 +50,8 @@ export interface App { files: string[]; createdAt: Date; updatedAt: Date; + githubOrg: string | null; + githubRepo: string | null; } export interface Version { diff --git a/src/pages/app-details.tsx b/src/pages/app-details.tsx index d507d4c..895e6c2 100644 --- a/src/pages/app-details.tsx +++ b/src/pages/app-details.tsx @@ -238,7 +238,7 @@ export default function AppDetailsPage() { Open in Chat - +
{/* Rename Dialog */} diff --git a/src/paths/paths.ts b/src/paths/paths.ts index cfa94ee..6f71cc4 100644 --- a/src/paths/paths.ts +++ b/src/paths/paths.ts @@ -15,7 +15,7 @@ export function getUserDataPath(): string { const electron = getElectron(); // When running in Electron and app is ready - if (process.env.NODE_ENV !== "development") { + if (process.env.NODE_ENV !== "development" && electron) { return electron!.app.getPath("userData"); } diff --git a/src/preload.ts b/src/preload.ts index ac096dc..69f8d05 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -33,6 +33,8 @@ const validInvokeChannels = [ "reset-all", "nodejs-status", "github:start-flow", + "github:is-repo-available", + "github:create-repo", ] as const; // Add valid receive channels