GitHub workflows (#428)

Fixes #348 
Fixes #274 
Fixes #149 

- Connect to existing repos
- Push to other branches on GitHub besides main
- Allows force push (with confirmation) dialog

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
This commit is contained in:
Will Chen
2025-06-17 16:59:26 -07:00
committed by GitHub
parent 9694e4a2e8
commit bd809a010d
24 changed files with 2686 additions and 237 deletions

View File

@@ -1,19 +1,281 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Github, Clipboard, Check } from "lucide-react";
import {
Github,
Clipboard,
Check,
AlertTriangle,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
import { useLoadApp } from "@/hooks/useLoadApp";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface GitHubConnectorProps {
appId: number | null;
folderName: string;
}
export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
interface GitHubRepo {
name: string;
full_name: string;
private: boolean;
}
interface GitHubBranch {
name: string;
commit: { sha: string };
}
interface ConnectedGitHubConnectorProps {
appId: number;
app: any;
refreshApp: () => void;
}
interface UnconnectedGitHubConnectorProps {
appId: number | null;
folderName: string;
settings: any;
refreshSettings: () => void;
refreshApp: () => void;
}
function ConnectedGitHubConnector({
appId,
app,
refreshApp,
}: ConnectedGitHubConnectorProps) {
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null);
const [syncSuccess, setSyncSuccess] = useState<boolean>(false);
const [showForceDialog, setShowForceDialog] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [disconnectError, setDisconnectError] = useState<string | null>(null);
const handleDisconnectRepo = async () => {
setIsDisconnecting(true);
setDisconnectError(null);
try {
await IpcClient.getInstance().disconnectGithubRepo(appId);
refreshApp();
} catch (err: any) {
setDisconnectError(err.message || "Failed to disconnect repository.");
} finally {
setIsDisconnecting(false);
}
};
const handleSyncToGithub = async (force: boolean = false) => {
setIsSyncing(true);
setSyncError(null);
setSyncSuccess(false);
setShowForceDialog(false);
try {
const result = await IpcClient.getInstance().syncGithubRepo(appId, force);
if (result.success) {
setSyncSuccess(true);
} else {
setSyncError(result.error || "Failed to sync to GitHub.");
// If it's a push rejection error, show the force dialog
if (
result.error?.includes("rejected") ||
result.error?.includes("non-fast-forward")
) {
// Don't show force dialog immediately, let user see the error first
}
}
} catch (err: any) {
setSyncError(err.message || "Failed to sync to GitHub.");
} finally {
setIsSyncing(false);
}
};
return (
<div
className="mt-4 w-full border border-gray-200 rounded-md p-4"
data-testid="github-connected-repo"
>
<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>
{app.githubBranch && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
Branch: <span className="font-mono">{app.githubBranch}</span>
</p>
)}
<div className="mt-2 flex gap-2">
<Button onClick={() => handleSyncToGithub(false)} disabled={isSyncing}>
{isSyncing ? (
<>
<svg
className="animate-spin h-5 w-5 mr-2 inline"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
style={{ display: "inline" }}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Syncing...
</>
) : (
"Sync to GitHub"
)}
</Button>
<Button
onClick={handleDisconnectRepo}
disabled={isDisconnecting}
variant="outline"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from repo"}
</Button>
</div>
{syncError && (
<div className="mt-2">
<p className="text-red-600">
{syncError}{" "}
<a
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/docs/integrations/github#troubleshooting",
);
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
>
See troubleshooting guide
</a>
</p>
{(syncError.includes("rejected") ||
syncError.includes("non-fast-forward")) && (
<Button
onClick={() => setShowForceDialog(true)}
variant="outline"
size="sm"
className="mt-2 text-orange-600 border-orange-600 hover:bg-orange-50"
>
<AlertTriangle className="h-4 w-4 mr-2" />
Force Push (Dangerous)
</Button>
)}
</div>
)}
{syncSuccess && (
<p className="text-green-600 mt-2">Successfully pushed to GitHub!</p>
)}
{disconnectError && (
<p className="text-red-600 mt-2">{disconnectError}</p>
)}
{/* Force Push Warning Dialog */}
<Dialog open={showForceDialog} onOpenChange={setShowForceDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-500" />
Force Push Warning
</DialogTitle>
<DialogDescription>
<div className="space-y-3">
<p>
You are about to perform a <strong>force push</strong> to your
GitHub repository.
</p>
<div className="bg-orange-50 dark:bg-orange-900/20 p-3 rounded-md border border-orange-200 dark:border-orange-800">
<p className="text-sm text-orange-800 dark:text-orange-200">
<strong>
This is dangerous and non-reversible and will:
</strong>
</p>
<ul className="text-sm text-orange-700 dark:text-orange-300 list-disc list-inside mt-2 space-y-1">
<li>Overwrite the remote repository history</li>
<li>
Permanently delete commits that exist on the remote but
not locally
</li>
</ul>
</div>
<p className="text-sm">
Only proceed if you're certain this is what you want to do.
</p>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowForceDialog(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => handleSyncToGithub(true)}
disabled={isSyncing}
>
{isSyncing ? "Force Pushing..." : "Force Push"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function UnconnectedGitHubConnector({
appId,
folderName,
settings,
refreshSettings,
refreshApp,
}: UnconnectedGitHubConnectorProps) {
// --- Collapsible State ---
const [isExpanded, setIsExpanded] = useState(false);
// --- GitHub Device Flow State ---
const { app, refreshApp } = useLoadApp(appId);
const { settings, refreshSettings } = useSettings();
const [githubUserCode, setGithubUserCode] = useState<string | null>(null);
const [githubVerificationUri, setGithubVerificationUri] = useState<
string | null
@@ -24,7 +286,37 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
null,
);
const [codeCopied, setCodeCopied] = useState(false);
// --- ---
// --- Repo Setup State ---
const [repoSetupMode, setRepoSetupMode] = useState<"create" | "existing">(
"create",
);
const [availableRepos, setAvailableRepos] = useState<GitHubRepo[]>([]);
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
const [selectedRepo, setSelectedRepo] = useState<string>("");
const [availableBranches, setAvailableBranches] = useState<GitHubBranch[]>(
[],
);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [selectedBranch, setSelectedBranch] = useState<string>("main");
const [branchInputMode, setBranchInputMode] = useState<"select" | "custom">(
"select",
);
const [customBranchName, setCustomBranchName] = useState<string>("");
// Create new 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)
const githubOrg = ""; // Use empty string for now (GitHub API will default to the authenticated user)
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleConnectToGithub = async () => {
if (!appId) return;
@@ -78,7 +370,7 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
setGithubError(null);
setIsConnectingToGithub(false);
refreshSettings();
// TODO: Maybe update parent UI to show "Connected" state or trigger next action
setIsExpanded(true);
});
cleanupFunctions.push(removeSuccessListener);
@@ -98,9 +390,6 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
// Cleanup function to remove all listeners when component unmounts or appId changes
return () => {
cleanupFunctions.forEach((cleanup) => cleanup());
// Optional: Send a message to main process to cancel polling if component unmounts
// Only cancel if we were actually connecting for this specific appId
// IpcClient.getInstance().cancelGithubDeviceFlow(appId);
// Reset state when appId changes or component unmounts
setGithubUserCode(null);
setGithubVerificationUri(null);
@@ -110,23 +399,58 @@ export function GitHubConnector({ appId, folderName }: 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);
// --- Sync to GitHub State ---
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null);
const [syncSuccess, setSyncSuccess] = 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)
// Load available repos when GitHub is connected
useEffect(() => {
if (settings?.githubAccessToken && repoSetupMode === "existing") {
loadAvailableRepos();
}
}, [settings?.githubAccessToken, repoSetupMode]);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const loadAvailableRepos = async () => {
setIsLoadingRepos(true);
try {
const repos = await IpcClient.getInstance().listGithubRepos();
setAvailableRepos(repos);
} catch (error) {
console.error("Failed to load GitHub repos:", error);
} finally {
setIsLoadingRepos(false);
}
};
// Load branches when a repo is selected
useEffect(() => {
if (selectedRepo && repoSetupMode === "existing") {
loadRepoBranches();
}
}, [selectedRepo, repoSetupMode]);
const loadRepoBranches = async () => {
if (!selectedRepo) return;
setIsLoadingBranches(true);
setBranchInputMode("select"); // Reset to select mode when loading new repo
setCustomBranchName(""); // Clear custom branch name
try {
const [owner, repo] = selectedRepo.split("/");
const branches = await IpcClient.getInstance().getGithubRepoBranches(
owner,
repo,
);
setAvailableBranches(branches);
// Default to main if available, otherwise first branch
const defaultBranch =
branches.find((b) => b.name === "main" || b.name === "master") ||
branches[0];
if (defaultBranch) {
setSelectedBranch(defaultBranch.name);
}
} catch (error) {
console.error("Failed to load repo branches:", error);
} finally {
setIsLoadingBranches(false);
}
};
const checkRepoAvailability = useCallback(
async (name: string) => {
@@ -166,48 +490,49 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
[checkRepoAvailability],
);
const handleCreateRepo = async (e: React.FormEvent) => {
const handleSetupRepo = async (e: React.FormEvent) => {
e.preventDefault();
if (!appId) return;
setCreateRepoError(null);
setIsCreatingRepo(true);
setCreateRepoSuccess(false);
try {
await IpcClient.getInstance().createGithubRepo(
githubOrg,
repoName,
appId!,
);
if (repoSetupMode === "create") {
await IpcClient.getInstance().createGithubRepo(
githubOrg,
repoName,
appId,
selectedBranch,
);
} else {
const [owner, repo] = selectedRepo.split("/");
const branchToUse =
branchInputMode === "custom" ? customBranchName : selectedBranch;
await IpcClient.getInstance().connectToExistingGithubRepo(
owner,
repo,
branchToUse,
appId,
);
}
setCreateRepoSuccess(true);
setRepoCheckError(null);
refreshApp();
} catch (err: any) {
setCreateRepoError(err.message || "Failed to create repository.");
setCreateRepoError(
err.message ||
`Failed to ${repoSetupMode === "create" ? "create" : "connect to"} repository.`,
);
} finally {
setIsCreatingRepo(false);
}
};
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [disconnectError, setDisconnectError] = useState<string | null>(null);
const handleDisconnectRepo = async () => {
if (!appId) return;
setIsDisconnecting(true);
setDisconnectError(null);
try {
await IpcClient.getInstance().disconnectGithubRepo(appId);
refreshApp();
} catch (err: any) {
setDisconnectError(err.message || "Failed to disconnect repository.");
} finally {
setIsDisconnecting(false);
}
};
if (!settings?.githubAccessToken) {
return (
<div className="mt-1 w-full">
{" "}
<div className="mt-1 w-full" data-testid="github-unconnected-repo">
<Button
onClick={handleConnectToGithub}
className="cursor-pointer w-full py-5 flex justify-center items-center gap-2"
@@ -310,149 +635,271 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
);
}
if (app?.githubOrg && app?.githubRepo) {
const handleSyncToGithub = async () => {
setIsSyncing(true);
setSyncError(null);
setSyncSuccess(false);
try {
const result = await IpcClient.getInstance().syncGithubRepo(appId!);
if (result.success) {
setSyncSuccess(true);
} else {
setSyncError(result.error || "Failed to sync to GitHub.");
}
} catch (err: any) {
setSyncError(err.message || "Failed to sync to GitHub.");
} finally {
setIsSyncing(false);
}
};
return (
<div
className="mt-4 w-full border border-gray-200 rounded-md"
data-testid="github-setup-repo"
>
{/* Collapsible Header */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className={`cursor-pointer w-full p-4 text-left transition-colors rounded-md flex items-center justify-between ${
!isExpanded ? "hover:bg-gray-50 dark:hover:bg-gray-800/50" : ""
}`}
>
<span className="font-medium">Set up your GitHub repo</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
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 className="mt-2 flex gap-2">
<Button onClick={handleSyncToGithub} disabled={isSyncing}>
{isSyncing ? (
{/* Collapsible Content */}
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
isExpanded ? "max-h-[800px] opacity-100" : "max-h-0 opacity-0"
}`}
>
<div className="p-4 pt-0 space-y-4">
{/* Mode Selection */}
<div>
<div className="flex rounded-md border border-gray-200 dark:border-gray-700">
<Button
type="button"
variant={repoSetupMode === "create" ? "default" : "ghost"}
className={`flex-1 rounded-none rounded-l-md border-0 ${
repoSetupMode === "create"
? "bg-primary text-primary-foreground"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => {
setRepoSetupMode("create");
setCreateRepoError(null);
setCreateRepoSuccess(false);
}}
>
Create new repo
</Button>
<Button
type="button"
variant={repoSetupMode === "existing" ? "default" : "ghost"}
className={`flex-1 rounded-none rounded-r-md border-0 border-l border-gray-200 dark:border-gray-700 ${
repoSetupMode === "existing"
? "bg-primary text-primary-foreground"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => {
setRepoSetupMode("existing");
setCreateRepoError(null);
setCreateRepoSuccess(false);
}}
>
Connect to existing repo
</Button>
</div>
</div>
<form className="space-y-4" onSubmit={handleSetupRepo}>
{repoSetupMode === "create" ? (
<>
<svg
className="animate-spin h-5 w-5 mr-2 inline"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
style={{ display: "inline" }}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Syncing...
<div>
<Label className="block text-sm font-medium">
Repository Name
</Label>
<Input
data-testid="github-create-repo-name-input"
className="w-full mt-1"
value={repoName}
onChange={(e) => {
const newValue = e.target.value;
setRepoName(newValue);
setRepoAvailable(null);
setRepoCheckError(null);
debouncedCheckRepoAvailability(newValue);
}}
disabled={isCreatingRepo}
/>
{isCheckingRepo && (
<p className="text-xs text-gray-500 mt-1">
Checking availability...
</p>
)}
{repoAvailable === true && (
<p className="text-xs text-green-600 mt-1">
Repository name is available!
</p>
)}
{repoAvailable === false && (
<p className="text-xs text-red-600 mt-1">
{repoCheckError}
</p>
)}
</div>
</>
) : (
"Sync to GitHub"
<>
<div>
<Label className="block text-sm font-medium">
Select Repository
</Label>
<Select
value={selectedRepo}
onValueChange={setSelectedRepo}
disabled={isLoadingRepos}
>
<SelectTrigger
className="w-full mt-1"
data-testid="github-repo-select"
>
<SelectValue
placeholder={
isLoadingRepos
? "Loading repositories..."
: "Select a repository"
}
/>
</SelectTrigger>
<SelectContent>
{availableRepos.map((repo) => (
<SelectItem key={repo.full_name} value={repo.full_name}>
{repo.full_name} {repo.private && "(private)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</Button>
<Button
onClick={handleDisconnectRepo}
disabled={isDisconnecting}
variant="outline"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from repo"}
</Button>
</div>
{syncError && (
<p className="text-red-600 mt-2">
{syncError}{" "}
<a
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/docs/integrations/github#troubleshooting",
);
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
{/* Branch Selection */}
<div>
<Label className="block text-sm font-medium">Branch</Label>
{repoSetupMode === "existing" && selectedRepo ? (
<div className="space-y-2">
<Select
value={
branchInputMode === "select" ? selectedBranch : "custom"
}
onValueChange={(value) => {
if (value === "custom") {
setBranchInputMode("custom");
setCustomBranchName("");
} else {
setBranchInputMode("select");
setSelectedBranch(value);
}
}}
disabled={isLoadingBranches}
>
<SelectTrigger
className="w-full mt-1"
data-testid="github-branch-select"
>
<SelectValue
placeholder={
isLoadingBranches
? "Loading branches..."
: "Select a branch"
}
/>
</SelectTrigger>
<SelectContent>
{availableBranches.map((branch) => (
<SelectItem key={branch.name} value={branch.name}>
{branch.name}
</SelectItem>
))}
<SelectItem value="custom">
<span className="font-medium">
Type custom branch name
</span>
</SelectItem>
</SelectContent>
</Select>
{branchInputMode === "custom" && (
<Input
data-testid="github-custom-branch-input"
className="w-full"
value={customBranchName}
onChange={(e) => setCustomBranchName(e.target.value)}
placeholder="Enter branch name (e.g., feature/new-feature)"
disabled={isCreatingRepo}
/>
)}
</div>
) : (
<Input
className="w-full mt-1"
value={selectedBranch}
onChange={(e) => setSelectedBranch(e.target.value)}
placeholder="main"
disabled={isCreatingRepo}
data-testid="github-new-repo-branch-input"
/>
)}
</div>
<Button
type="submit"
disabled={
isCreatingRepo ||
(repoSetupMode === "create" &&
(repoAvailable === false || !repoName)) ||
(repoSetupMode === "existing" &&
(!selectedRepo ||
!selectedBranch ||
(branchInputMode === "custom" && !customBranchName.trim())))
}
>
See troubleshooting guide
</a>
</p>
)}
{syncSuccess && (
<p className="text-green-600 mt-2">Successfully pushed to GitHub!</p>
)}
{disconnectError && (
<p className="text-red-600 mt-2">{disconnectError}</p>
)}
{isCreatingRepo
? repoSetupMode === "create"
? "Creating..."
: "Connecting..."
: repoSetupMode === "create"
? "Create Repo"
: "Connect to Repo"}
</Button>
</form>
{createRepoError && (
<p className="text-red-600 mt-2">{createRepoError}</p>
)}
{createRepoSuccess && (
<p className="text-green-600 mt-2">
{repoSetupMode === "create"
? "Repository created and linked!"
: "Connected to repository!"}
</p>
)}
</div>
</div>
</div>
);
}
export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
const { app, refreshApp } = useLoadApp(appId);
const { settings, refreshSettings } = useSettings();
if (app?.githubOrg && app?.githubRepo && appId) {
return (
<ConnectedGitHubConnector
appId={appId}
app={app}
refreshApp={refreshApp}
/>
);
} else {
return (
<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) => {
const newValue = e.target.value;
setRepoName(newValue);
setRepoAvailable(null);
setRepoCheckError(null);
debouncedCheckRepoAvailability(newValue);
}}
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>
<UnconnectedGitHubConnector
appId={appId}
folderName={folderName}
settings={settings}
refreshSettings={refreshSettings}
refreshApp={refreshApp}
/>
);
}
}

View File

@@ -8,7 +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";
import log from "electron-log";
const logger = log.scope("db");
@@ -87,14 +87,3 @@ 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

@@ -14,6 +14,7 @@ export const apps = sqliteTable("apps", {
.default(sql`(unixepoch())`),
githubOrg: text("github_org"),
githubRepo: text("github_repo"),
githubBranch: text("github_branch"),
supabaseProjectId: text("supabase_project_id"),
chatContext: text("chat_context", { mode: "json" }),
});

View File

@@ -1,10 +1,9 @@
import { ipcMain, BrowserWindow, IpcMainInvokeEvent } from "electron";
import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process
import { writeSettings, readSettings } from "../../main/settings";
import { updateAppGithubRepo } from "../../db/index";
import git from "isomorphic-git";
import http from "isomorphic-git/http/node";
import * as schema from "../../db/schema";
import fs from "node:fs";
import { getDyadAppPath } from "../../paths/paths";
import { db } from "../../db";
@@ -12,14 +11,31 @@ import { apps } from "../../db/schema";
import { eq } from "drizzle-orm";
import { GithubUser } from "../../lib/schemas";
import log from "electron-log";
import { IS_TEST_BUILD } from "../utils/test_utils";
const logger = log.scope("github_handlers");
// --- GitHub Device Flow Constants ---
// TODO: Fetch this securely, e.g., from environment variables or a config file
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || "Ov23liWV2HdC0RBLecWx";
const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
const GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
// Use test server URLs when in test mode
const TEST_SERVER_BASE = "http://localhost:3500";
const GITHUB_DEVICE_CODE_URL = IS_TEST_BUILD
? `${TEST_SERVER_BASE}/github/login/device/code`
: "https://github.com/login/device/code";
const GITHUB_ACCESS_TOKEN_URL = IS_TEST_BUILD
? `${TEST_SERVER_BASE}/github/login/oauth/access_token`
: "https://github.com/login/oauth/access_token";
const GITHUB_API_BASE = IS_TEST_BUILD
? `${TEST_SERVER_BASE}/github/api`
: "https://api.github.com";
const GITHUB_GIT_BASE = IS_TEST_BUILD
? `${TEST_SERVER_BASE}/github/git`
: "https://github.com";
const GITHUB_SCOPES = "repo,user,workflow"; // Define the scopes needed
// --- State Management (Simple in-memory, consider alternatives for robustness) ---
@@ -48,7 +64,7 @@ export async function getGithubUser(): Promise<GithubUser | null> {
try {
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) return null;
const res = await fetch("https://api.github.com/user/emails", {
const res = await fetch(`${GITHUB_API_BASE}/user/emails`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return null;
@@ -281,6 +297,90 @@ function handleStartGithubFlow(
});
}
// --- GitHub List Repos Handler ---
async function handleListGithubRepos(): Promise<
{ name: string; full_name: string; private: boolean }[]
> {
try {
// Get access token from settings
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
}
// Fetch user's repositories
const response = await fetch(
`${GITHUB_API_BASE}/user/repos?per_page=100&sort=updated`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(
`GitHub API error: ${errorData.message || response.statusText}`,
);
}
const repos = await response.json();
return repos.map((repo: any) => ({
name: repo.name,
full_name: repo.full_name,
private: repo.private,
}));
} catch (err: any) {
logger.error("[GitHub Handler] Failed to list repos:", err);
throw new Error(err.message || "Failed to list GitHub repositories.");
}
}
// --- GitHub Get Repo Branches Handler ---
async function handleGetRepoBranches(
event: IpcMainInvokeEvent,
{ owner, repo }: { owner: string; repo: string },
): Promise<{ name: string; commit: { sha: string } }[]> {
try {
// Get access token from settings
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
}
// Fetch repository branches
const response = await fetch(
`${GITHUB_API_BASE}/repos/${owner}/${repo}/branches`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(
`GitHub API error: ${errorData.message || response.statusText}`,
);
}
const branches = await response.json();
return branches.map((branch: any) => ({
name: branch.name,
commit: { sha: branch.commit.sha },
}));
} catch (err: any) {
logger.error("[GitHub Handler] Failed to get repo branches:", err);
throw new Error(err.message || "Failed to get repository branches.");
}
}
// --- GitHub Repo Availability Handler ---
async function handleIsRepoAvailable(
event: IpcMainInvokeEvent,
@@ -296,13 +396,13 @@ async function handleIsRepoAvailable(
// If org is empty, use the authenticated user
const owner =
org ||
(await fetch("https://api.github.com/user", {
(await fetch(`${GITHUB_API_BASE}/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 url = `${GITHUB_API_BASE}/repos/${owner}/${repo}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
@@ -322,7 +422,12 @@ async function handleIsRepoAvailable(
// --- GitHub Create Repo Handler ---
async function handleCreateRepo(
event: IpcMainInvokeEvent,
{ org, repo, appId }: { org: string; repo: string; appId: number },
{
org,
repo,
appId,
branch,
}: { org: string; repo: string; appId: number; branch?: string },
): Promise<void> {
// Get access token from settings
const settings = readSettings();
@@ -333,7 +438,7 @@ async function handleCreateRepo(
// If org is empty, create for the authenticated user
let owner = org;
if (!owner) {
const userRes = await fetch("https://api.github.com/user", {
const userRes = await fetch(`${GITHUB_API_BASE}/user`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const user = await userRes.json();
@@ -341,8 +446,8 @@ async function handleCreateRepo(
}
// Create repo
const createUrl = org
? `https://api.github.com/orgs/${owner}/repos`
: `https://api.github.com/user/repos`;
? `${GITHUB_API_BASE}/orgs/${owner}/repos`
: `${GITHUB_API_BASE}/user/repos`;
const res = await fetch(createUrl, {
method: "POST",
headers: {
@@ -395,14 +500,58 @@ async function handleCreateRepo(
throw new Error(errorMessage);
}
// Store org and repo in the app's DB row (apps table)
await updateAppGithubRepo(appId, owner, repo);
// Store org, repo, and branch in the app's DB row (apps table)
await updateAppGithubRepo({ appId, org: owner, repo, branch });
}
// --- GitHub Connect to Existing Repo Handler ---
async function handleConnectToExistingRepo(
event: IpcMainInvokeEvent,
{
owner,
repo,
branch,
appId,
}: { owner: string; repo: string; branch: string; appId: number },
): Promise<void> {
try {
// Get access token from settings
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
}
// Verify the repository exists and user has access
const repoResponse = await fetch(
`${GITHUB_API_BASE}/repos/${owner}/${repo}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
},
},
);
if (!repoResponse.ok) {
const errorData = await repoResponse.json();
throw new Error(
`Repository not found or access denied: ${errorData.message}`,
);
}
// Store org, repo, and branch in the app's DB row
await updateAppGithubRepo({ appId, org: owner, repo, branch });
} catch (err: any) {
logger.error("[GitHub Handler] Failed to connect to existing repo:", err);
throw new Error(err.message || "Failed to connect to existing repository.");
}
}
// --- GitHub Push Handler ---
async function handlePushToGithub(
event: IpcMainInvokeEvent,
{ appId }: { appId: number },
{ appId, force }: { appId: number; force?: boolean },
) {
try {
// Get access token from settings
@@ -417,8 +566,12 @@ async function handlePushToGithub(
return { success: false, error: "App is not linked to a GitHub repo." };
}
const appPath = getDyadAppPath(app.path);
const branch = app.githubBranch || "main";
// Set up remote URL with token
const remoteUrl = `https://${accessToken}:x-oauth-basic@github.com/${app.githubOrg}/${app.githubRepo}.git`;
const remoteUrl = IS_TEST_BUILD
? `${GITHUB_GIT_BASE}/${app.githubOrg}/${app.githubRepo}.git`
: `https://${accessToken}:x-oauth-basic@github.com/${app.githubOrg}/${app.githubRepo}.git`;
// Set or update remote URL using git config
await git.setConfig({
fs,
@@ -433,11 +586,12 @@ async function handlePushToGithub(
dir: appPath,
remote: "origin",
ref: "main",
remoteRef: branch,
onAuth: () => ({
username: accessToken,
password: "x-oauth-basic",
}),
force: false,
force: !!force,
});
return { success: true };
} catch (err: any) {
@@ -463,12 +617,13 @@ async function handleDisconnectGithubRepo(
throw new Error("App not found");
}
// Update app in database to remove GitHub repo and org
// Update app in database to remove GitHub repo, org, and branch
await db
.update(apps)
.set({
githubRepo: null,
githubOrg: null,
githubBranch: null,
})
.where(eq(apps.id, appId));
}
@@ -476,10 +631,44 @@ async function handleDisconnectGithubRepo(
// --- Registration ---
export function registerGithubHandlers() {
ipcMain.handle("github:start-flow", handleStartGithubFlow);
ipcMain.handle("github:list-repos", handleListGithubRepos);
ipcMain.handle(
"github:get-repo-branches",
(event, args: { owner: string; repo: string }) =>
handleGetRepoBranches(event, args),
);
ipcMain.handle("github:is-repo-available", handleIsRepoAvailable);
ipcMain.handle("github:create-repo", handleCreateRepo);
ipcMain.handle(
"github:connect-existing-repo",
(
event,
args: { owner: string; repo: string; branch: string; appId: number },
) => handleConnectToExistingRepo(event, args),
);
ipcMain.handle("github:push", handlePushToGithub);
ipcMain.handle("github:disconnect", (event, args: { appId: number }) =>
handleDisconnectGithubRepo(event, args),
);
}
export async function updateAppGithubRepo({
appId,
org,
repo,
branch,
}: {
appId: number;
org?: string;
repo: string;
branch?: string;
}): Promise<void> {
await db
.update(schema.apps)
.set({
githubOrg: org,
githubRepo: repo,
githubBranch: branch || "main",
})
.where(eq(schema.apps.id, appId));
}

View File

@@ -554,6 +554,36 @@ export class IpcClient {
// --- End GitHub Device Flow ---
// --- GitHub Repo Management ---
public async listGithubRepos(): Promise<
{ name: string; full_name: string; private: boolean }[]
> {
return this.ipcRenderer.invoke("github:list-repos");
}
public async getGithubRepoBranches(
owner: string,
repo: string,
): Promise<{ name: string; commit: { sha: string } }[]> {
return this.ipcRenderer.invoke("github:get-repo-branches", {
owner,
repo,
});
}
public async connectToExistingGithubRepo(
owner: string,
repo: string,
branch: string,
appId: number,
): Promise<void> {
await this.ipcRenderer.invoke("github:connect-existing-repo", {
owner,
repo,
branch,
appId,
});
}
public async checkGithubRepoAvailable(
org: string,
repo: string,
@@ -568,25 +598,25 @@ export class IpcClient {
org: string,
repo: string,
appId: number,
branch?: string,
): Promise<void> {
await this.ipcRenderer.invoke("github:create-repo", {
org,
repo,
appId,
branch,
});
}
// Sync (push) local repo to GitHub
public async syncGithubRepo(
appId: number,
force?: boolean,
): Promise<{ success: boolean; error?: string }> {
try {
const result = await this.ipcRenderer.invoke("github:push", { appId });
return result as { success: boolean; error?: string };
} catch (error) {
showError(error);
throw error;
}
return this.ipcRenderer.invoke("github:push", {
appId,
force,
});
}
public async disconnectGithubRepo(appId: number): Promise<void> {

View File

@@ -70,6 +70,7 @@ export interface App {
updatedAt: Date;
githubOrg: string | null;
githubRepo: string | null;
githubBranch: string | null;
supabaseProjectId: string | null;
supabaseProjectName: string | null;
}

View File

@@ -46,8 +46,11 @@ const validInvokeChannels = [
"nodejs-status",
"install-node",
"github:start-flow",
"github:list-repos",
"github:get-repo-branches",
"github:is-repo-available",
"github:create-repo",
"github:connect-existing-repo",
"github:push",
"github:disconnect",
"get-app-version",