github repo creation flow

This commit is contained in:
Will Chen
2025-04-14 21:55:51 -07:00
parent 7ad83a2bdc
commit 05a97d31c4
12 changed files with 288 additions and 10 deletions

View File

@@ -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<string | null>(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<boolean | null>(null);
const [repoCheckError, setRepoCheckError] = useState<string | null>(null);
const [isCheckingRepo, setIsCheckingRepo] = useState(false);
const [isCreatingRepo, setIsCreatingRepo] = useState(false);
const [createRepoError, setCreateRepoError] = useState<string | null>(null);
const [createRepoSuccess, setCreateRepoSuccess] = useState<boolean>(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 (
<div className="mt-4 w-full border border-gray-200 rounded-md p-4">
<p>Connected to GitHub Repo:</p>
<a
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
`https://github.com/${app.githubOrg}/${app.githubRepo}`
);
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
>
{app.githubOrg}/{app.githubRepo}
</a>
</div>
);
}
if (settings?.githubSettings.secrets) {
return (
<div className="mt-4 w-full">
<p>Connected to GitHub!</p>
<div className="mt-4 w-full border border-gray-200 rounded-md p-4">
<p>Set up your GitHub repo</p>
<form className="mt-4 space-y-2" onSubmit={handleCreateRepo}>
<label className="block text-sm font-medium">Repository Name</label>
<input
className="w-full border rounded px-2 py-1"
value={repoName}
onChange={(e) => {
setRepoName(e.target.value);
setRepoAvailable(null);
setRepoCheckError(null);
}}
onBlur={handleRepoNameBlur}
disabled={isCreatingRepo}
/>
{isCheckingRepo && (
<p className="text-xs text-gray-500">Checking availability...</p>
)}
{repoAvailable === true && (
<p className="text-xs text-green-600">
Repository name is available!
</p>
)}
{repoAvailable === false && (
<p className="text-xs text-red-600">{repoCheckError}</p>
)}
<Button
type="submit"
disabled={isCreatingRepo || repoAvailable === false || !repoName}
>
{isCreatingRepo ? "Creating..." : "Create Repo"}
</Button>
</form>
{createRepoError && (
<p className="text-red-600 mt-2">{createRepoError}</p>
)}
{createRepoSuccess && (
<p className="text-green-600 mt-2">Repository created and linked!</p>
)}
</div>
);
}

View File

@@ -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<typeof drizzle> | null = null;
@@ -91,3 +92,14 @@ try {
export const db = _db as any as BetterSQLite3Database<typeof schema> & {
$client: Database.Database;
};
export async function updateAppGithubRepo(
appId: number,
org: string,
repo: string
): Promise<void> {
await db
.update(schema.apps)
.set({ githubOrg: org, githubRepo: repo })
.where(eq(schema.apps.id, appId));
}

View File

@@ -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", {

View File

@@ -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);
}

View File

@@ -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 {
}

View File

@@ -50,6 +50,8 @@ export interface App {
files: string[];
createdAt: Date;
updatedAt: Date;
githubOrg: string | null;
githubRepo: string | null;
}
export interface Version {

View File

@@ -238,7 +238,7 @@ export default function AppDetailsPage() {
Open in Chat
<MessageCircle className="h-5 w-5" />
</Button>
<GitHubConnector appId={appId} />
<GitHubConnector appId={appId} folderName={selectedApp.path} />
</div>
{/* Rename Dialog */}

View File

@@ -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");
}

View File

@@ -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