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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface App {
|
||||
updatedAt: Date;
|
||||
githubOrg: string | null;
|
||||
githubRepo: string | null;
|
||||
githubBranch: string | null;
|
||||
supabaseProjectId: string | null;
|
||||
supabaseProjectName: string | null;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user