Unverified 提交 348521ce authored 作者: Adeniji Adekunle James's avatar Adeniji Adekunle James 提交者: GitHub

GitHub Import Feature: Import repositories/projects from GitHub (#1424) (#1454)

## Summary Adds the ability to import GitHub repositories directly into Dyad from the home screen, complementing the existing local folder import feature. - GitHub Import Modal: New modal accessible from home screen via "Import from Github" button with two Import methods - Select project from GitHub repositories list - Clone from any GitHub URL - Advanced Options: Optional custom install/start commands (defaults to project's package.json scripts) - Auto AI_RULES Generation: Automatically generates AI_RULES.md if not present in imported repo closes #1424 <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a GitHub import flow from the home screen so users can clone repos via their list or any URL, with optional install/start commands and automatic AI_RULES.md generation. Addresses Linear #1424 by enabling seamless project setup from GitHub. - **New Features** - Import modal with two tabs: Your Repositories and From URL. - Advanced options for install/start commands with validation; defaults used when both are empty. - After cloning, navigate to chat and auto-generate AI_RULES.md if missing. - New IPC handler github:clone-repo-from-url with token auth support, plus IpcClient method and preload channel. - E2E tests cover modal open, auth, import via URL/repo list, and advanced options. - **Dependencies** - Added @radix-ui/react-tabs for the modal tab UI. <!-- End of auto-generated description by cubic. -->
上级 7acbe73c
import { expect } from "@playwright/test";
import { test } from "./helpers/test_helper";
test("should open GitHub import modal from home", async ({ po }) => {
await po.setUp();
// Click the "Import from Github" button
await po.page.getByRole("button", { name: "Import App" }).click();
// Verify modal opened with import UI (showing all tabs even when not authenticated)
await expect(
po.page.getByRole("heading", { name: "Import App" }),
).toBeVisible();
await expect(
po.page.getByText(
"Import existing app from local folder or clone from Github",
),
).toBeVisible();
// All tabs should be visible
await expect(
po.page.getByRole("tab", { name: "Local Folder" }),
).toBeVisible();
await expect(
po.page.getByRole("tab", { name: "Your GitHub Repos" }),
).toBeVisible();
await expect(po.page.getByRole("tab", { name: "GitHub URL" })).toBeVisible();
// Local Folder tab should be active by default
await expect(
po.page.getByRole("button", { name: "Select Folder" }),
).toBeVisible();
// Switch to Your GitHub Repos tab - should show GitHub connector
await po.page.getByRole("tab", { name: "Your GitHub Repos" }).click();
await expect(
po.page.getByRole("button", { name: "Connect to GitHub" }),
).toBeVisible();
});
test("should connect to GitHub and show import UI", async ({ po }) => {
await po.setUp();
// Open modal
await po.page.getByRole("button", { name: "Import App" }).click();
// Switch to Your GitHub Repos tab - should show GitHub connector when not authenticated
await po.page.getByRole("tab", { name: "Your GitHub Repos" }).click();
// Connect to GitHub (reuse existing connector)
await po.page.getByRole("button", { name: "Connect to GitHub" }).click();
// Wait for device flow code
await expect(po.page.locator("text=FAKE-CODE")).toBeVisible();
// After connection, should show repositories list instead of connector
await expect(po.page.getByText("testuser/existing-app")).toBeVisible();
// Should be able to see all tabs
await expect(
po.page.getByRole("tab", { name: "Your GitHub Repos" }),
).toBeVisible();
await expect(po.page.getByRole("tab", { name: "GitHub URL" })).toBeVisible();
await expect(
po.page.getByRole("tab", { name: "Local Folder" }),
).toBeVisible();
});
test("should import GitHub URL", async ({ po }) => {
await po.setUp();
// Open modal and connect
await po.page.getByRole("button", { name: "Import App" }).click();
await po.page.getByRole("tab", { name: "Your GitHub Repos" }).click();
await po.page.getByRole("button", { name: "Connect to GitHub" }).click();
await expect(po.page.locator("text=FAKE-CODE")).toBeVisible();
// Switch to "GitHub URL" tab
await po.page.getByRole("tab", { name: "GitHub URL" }).click();
// Enter URL
await po.page
.getByPlaceholder("https://github.com/user/repo.git")
.fill("https://github.com/dyad-sh/nextjs-template.git");
// Click import
await po.page.getByRole("button", { name: "Import", exact: true }).click();
// Should close modal and navigate to chat
await expect(
po.page.getByRole("heading", { name: "Import App" }),
).not.toBeVisible();
// Verify AI_RULES generation prompt was sent
});
test("should import from repository list", async ({ po }) => {
await po.setUp();
// Open modal and connect
await po.page.getByRole("button", { name: "Import App" }).click();
// Switch to Your GitHub Repos tab - should show GitHub connector when not authenticated
await po.page.getByRole("tab", { name: "Your GitHub Repos" }).click();
await po.page.getByRole("button", { name: "Connect to GitHub" }).click();
await expect(po.page.locator("text=FAKE-CODE")).toBeVisible();
// Switch to Your GitHub Repos tab
await po.page.getByRole("tab", { name: "Your GitHub Repos" }).click();
// Should show repositories list
await expect(po.page.getByText("testuser/existing-app")).toBeVisible();
// Click the first Import button in the repo list
await po.page.getByRole("button", { name: "Import" }).first().click();
// Should close modal and navigate to chat
await expect(
po.page.getByRole("heading", { name: "Import App" }),
).not.toBeVisible();
// Verify AI_RULES generation prompt
await po.snapshotMessages();
});
test("should support advanced options with custom commands", async ({ po }) => {
await po.setUp();
// Open modal and connect
await po.page.getByRole("button", { name: "Import App" }).click();
// Go to GitHub URL tab
await po.page.getByRole("tab", { name: "GitHub URL" }).click();
await po.page
.getByPlaceholder("https://github.com/user/repo.git")
.fill("https://github.com/dyad-sh/nextjs-template.git");
// Open advanced options
await po.page.getByRole("button", { name: "Advanced options" }).click();
// Fill one command - should show error
await po.page.getByPlaceholder("pnpm install").fill("npm install");
await expect(
po.page.getByText("Both commands are required when customizing"),
).toBeVisible();
await expect(
po.page.getByRole("button", { name: "Import", exact: true }),
).toBeDisabled();
// Fill both commands
await po.page.getByPlaceholder("pnpm dev").fill("npm start");
await expect(
po.page.getByRole("button", { name: "Import", exact: true }),
).toBeEnabled();
await expect(
po.page.getByText("Both commands are required when customizing"),
).not.toBeVisible();
// Import with custom commands
await po.page.getByRole("button", { name: "Import", exact: true }).click();
await expect(
po.page.getByRole("heading", { name: "Import App" }),
).not.toBeVisible();
});
test("should allow empty commands to use defaults", async ({ po }) => {
await po.setUp();
// Open modal and connect
await po.page.getByRole("button", { name: "Import App" }).click();
// Go to GitHub URL tab
await po.page.getByRole("tab", { name: "GitHub URL" }).click();
await po.page
.getByPlaceholder("https://github.com/user/repo.git")
.fill("https://github.com/dyad-sh/nextjs-template.git");
// Commands are empty by default, so import should be enabled
await expect(
po.page.getByRole("button", { name: "Import", exact: true }),
).toBeEnabled();
await po.page.getByRole("button", { name: "Import", exact: true }).click();
await expect(
po.page.getByRole("heading", { name: "Import App" }),
).not.toBeVisible();
});
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: less than a minute ago
- button "Retry":
- img
\ No newline at end of file
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: less than a minute ago
- button "Retry":
- img
\ No newline at end of file
......@@ -38,6 +38,7 @@
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.0",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.1.3",
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
......@@ -5515,6 +5516,36 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
......
......@@ -53,7 +53,7 @@ interface ConnectedGitHubConnectorProps {
onAutoSyncComplete?: () => void;
}
interface UnconnectedGitHubConnectorProps {
export interface UnconnectedGitHubConnectorProps {
appId: number | null;
folderName: string;
settings: any;
......@@ -287,7 +287,7 @@ function ConnectedGitHubConnector({
);
}
function UnconnectedGitHubConnector({
export function UnconnectedGitHubConnector({
appId,
folderName,
settings,
......@@ -342,7 +342,6 @@ function UnconnectedGitHubConnector({
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleConnectToGithub = async () => {
if (!appId) return;
setIsConnectingToGithub(true);
setGithubError(null);
setGithubUserCode(null);
......@@ -354,8 +353,6 @@ function UnconnectedGitHubConnector({
};
useEffect(() => {
if (!appId) return; // Don't set up listeners if appId is null initially
const cleanupFunctions: (() => void)[] = [];
// Listener for updates (user code, verification uri, status messages)
......@@ -420,7 +417,7 @@ function UnconnectedGitHubConnector({
setIsConnectingToGithub(false);
setGithubStatusMessage(null);
};
}, [appId]); // Re-run effect if appId changes
}, []); // Re-run effect if appId changes
// Load available repos when GitHub is connected
useEffect(() => {
......@@ -562,7 +559,7 @@ function UnconnectedGitHubConnector({
className="cursor-pointer w-full py-5 flex justify-center items-center gap-2"
size="lg"
variant="outline"
disabled={isConnectingToGithub || !appId} // Also disable if appId is null
disabled={isConnectingToGithub} // Also disable if appId is null
>
Connect to GitHub
<Github className="h-5 w-5" />
......
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
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 git from "isomorphic-git";
import git, { clone } 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";
import { apps } from "../../db/schema";
import type { CloneRepoParams, CloneRepoReturnType } from "@/ipc/ipc_types";
import { eq } from "drizzle-orm";
import { GithubUser } from "../../lib/schemas";
import log from "electron-log";
import { IS_TEST_BUILD } from "../utils/test_utils";
import path from "node:path"; // ← ADD THIS
const logger = log.scope("github_handlers");
......@@ -627,6 +629,115 @@ async function handleDisconnectGithubRepo(
})
.where(eq(apps.id, appId));
}
// --- GitHub Clone Repo from URL Handler ---
async function handleCloneRepoFromUrl(
event: IpcMainInvokeEvent,
params: CloneRepoParams,
): Promise<CloneRepoReturnType> {
const { url, installCommand, startCommand, appName } = params;
try {
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
const urlPattern = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
const match = url.match(urlPattern);
if (!match) {
return {
error:
"Invalid GitHub URL. Expected format: https://github.com/owner/repo.git",
};
}
const [, owner, repoName] = match;
if (accessToken) {
const repoResponse = await fetch(
`${GITHUB_API_BASE}/repos/${owner}/${repoName}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
},
},
);
if (!repoResponse.ok) {
return {
error: "Repository not found or you do not have access to it.",
};
}
}
const finalAppName = appName && appName.trim() ? appName.trim() : repoName;
const existingApp = await db.query.apps.findFirst({
where: eq(apps.name, finalAppName),
});
if (existingApp) {
return { error: `An app named "${finalAppName}" already exists.` };
}
const appPath = getDyadAppPath(finalAppName);
if (!fs.existsSync(appPath)) {
fs.mkdirSync(appPath, { recursive: true });
}
// Use authenticated URL if token exists, otherwise use public HTTPS URL
const cloneUrl = accessToken
? IS_TEST_BUILD
? `${GITHUB_GIT_BASE}/${owner}/${repoName}.git`
: `https://${accessToken}:x-oauth-basic@github.com/${owner}/${repoName}.git`
: `https://github.com/${owner}/${repoName}.git`; // Changed: use public HTTPS URL instead of original url
try {
await clone({
fs,
http,
dir: appPath,
url: cloneUrl,
onAuth: accessToken
? () => ({
username: accessToken,
password: "x-oauth-basic",
})
: undefined,
singleBranch: false,
});
} catch (cloneErr) {
logger.error("[GitHub Handler] Clone failed:", cloneErr);
return {
error:
"Failed to clone repository. Please check the URL and try again.",
};
}
const aiRulesPath = path.join(appPath, "AI_RULES.md");
const hasAiRules = fs.existsSync(aiRulesPath);
const [newApp] = await db
.insert(schema.apps)
.values({
name: finalAppName,
path: finalAppName,
createdAt: new Date(),
updatedAt: new Date(),
githubOrg: owner,
githubRepo: repoName,
githubBranch: "main",
installCommand: installCommand || null,
startCommand: startCommand || null,
})
.returning();
logger.log(`Successfully cloned repo ${owner}/${repoName} to ${appPath}`);
// Return success object
return {
app: {
...newApp,
files: [],
supabaseProjectName: null,
vercelTeamSlug: null,
},
hasAiRules,
};
} catch (err: any) {
// Catch any remaining unexpected errors and return an error object
logger.error("[GitHub Handler] Unexpected error in clone flow:", err);
return {
error: err.message || "An unexpected error occurred during cloning.",
};
}
}
// --- Registration ---
export function registerGithubHandlers() {
......@@ -650,6 +761,12 @@ export function registerGithubHandlers() {
ipcMain.handle("github:disconnect", (event, args: { appId: number }) =>
handleDisconnectGithubRepo(event, args),
);
ipcMain.handle(
"github:clone-repo-from-url",
async (event, args: CloneRepoParams) => {
return await handleCloneRepoFromUrl(event, args);
},
);
}
export async function updateAppGithubRepo({
......
......@@ -65,6 +65,7 @@ import type {
UpdatePromptParamsDto,
McpServerUpdate,
CreateMcpServer,
CloneRepoParams,
} from "./ipc_types";
import type { Template } from "../shared/templates";
import type {
......@@ -1277,6 +1278,11 @@ export class IpcClient {
public async deletePrompt(id: number): Promise<void> {
await this.ipcRenderer.invoke("prompts:delete", id);
}
public async cloneRepoFromUrl(
params: CloneRepoParams,
): Promise<{ app: App; hasAiRules: boolean } | { error: string }> {
return this.ipcRenderer.invoke("github:clone-repo-from-url", params);
}
// --- Help bot ---
public startHelpChat(
......
......@@ -488,3 +488,23 @@ export interface McpToolConsent {
consent: McpToolConsentType;
updatedAt: number;
}
export interface CloneRepoParams {
url: string;
installCommand?: string;
startCommand?: string;
appName: string;
}
export interface GithubRepository {
name: string;
full_name: string;
private: boolean;
}
export type CloneRepoReturnType =
| {
app: App;
hasAiRules: boolean;
}
| {
error: string;
};
......@@ -129,6 +129,7 @@ const validInvokeChannels = [
"prompts:delete",
// adding app to favorite
"add-to-favorite",
"github:clone-repo-from-url",
// Test-only channels
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
// We can't detect with IS_TEST_BUILD in the preload script because
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论