Unverified 提交 bd809a01 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

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: 's avatargraphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
上级 9694e4a2
ALTER TABLE `apps` ADD `github_branch` text;
\ No newline at end of file
差异被折叠。
...@@ -50,6 +50,13 @@ ...@@ -50,6 +50,13 @@
"when": 1749515724373, "when": 1749515724373,
"tag": "0006_mushy_squirrel_girl", "tag": "0006_mushy_squirrel_girl",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1750186036000,
"tag": "0007_dapper_overlord",
"breakpoints": true
} }
] ]
} }
\ No newline at end of file
import { expect } from "@playwright/test";
import { test } from "./helpers/test_helper";
test("should connect to GitHub using device flow", async ({ po }) => {
await po.setUp();
await po.sendPrompt("tc=basic");
await po.getTitleBarAppNameButton().click();
await po.githubConnector.connect();
// Wait for device flow to start and show the code
await expect(po.page.locator("text=FAKE-CODE")).toBeVisible();
// Verify the verification URI is displayed
await expect(
po.page.locator("text=https://github.com/login/device"),
).toBeVisible();
// Verify the "Set up your GitHub repo" section appears
await expect(po.githubConnector.getSetupYourGitHubRepoButton()).toBeVisible();
});
test("create and sync to new repo", async ({ po }) => {
await po.setUp();
await po.sendPrompt("tc=basic");
await po.getTitleBarAppNameButton().click();
await po.githubConnector.connect();
// Verify "Create new repo" is selected by default
await expect(po.githubConnector.getCreateNewRepoModeButton()).toHaveClass(
/bg-primary/,
);
await po.githubConnector.fillCreateRepoName("test-new-repo");
// Wait for availability check
await po.page.waitForSelector("text=Repository name is available!", {
timeout: 5000,
});
// Click create repo button
await po.githubConnector.clickCreateRepoButton();
// Snapshot post-creation state
await po.githubConnector.snapshotConnectedRepo();
// Sync: capture success message
await po.githubConnector.clickSyncToGithubButton();
await po.githubConnector.snapshotConnectedRepo();
// Verify the push was received for the default branch (main)
await po.githubConnector.verifyPushEvent({
repo: "test-new-repo",
branch: "main",
operation: "create",
});
});
test("create and sync to new repo - custom branch", async ({ po }) => {
await po.setUp();
await po.sendPrompt("tc=basic");
await po.getTitleBarAppNameButton().click();
await po.githubConnector.connect();
await po.githubConnector.fillCreateRepoName("test-new-repo");
await po.githubConnector.fillNewRepoBranchName("new-branch");
// Click create repo button
await po.githubConnector.clickCreateRepoButton();
// Sync to GitHub
await po.githubConnector.clickSyncToGithubButton();
// Snapshot post-creation state
await po.githubConnector.snapshotConnectedRepo();
// Verify the push was received for the correct custom branch
await po.githubConnector.verifyPushEvent({
repo: "test-new-repo",
branch: "new-branch",
operation: "create",
});
});
test("disconnect from repo", async ({ po }) => {
await po.setUp();
await po.sendPrompt("tc=basic");
await po.getTitleBarAppNameButton().click();
await po.githubConnector.connect();
await po.githubConnector.fillCreateRepoName("test-new-repo");
await po.githubConnector.clickCreateRepoButton();
await po.githubConnector.clickDisconnectRepoButton();
await po.githubConnector.getSetupYourGitHubRepoButton().click();
// Make this deterministic
await po.githubConnector.fillCreateRepoName("[scrubbed]");
await po.githubConnector.snapshotSetupRepo();
});
test("create and sync to existing repo", async ({ po }) => {
await po.setUp();
await po.sendPrompt("tc=basic");
await po.getTitleBarAppNameButton().click();
await po.githubConnector.connect();
await po.githubConnector.getConnectToExistingRepoModeButton().click();
await po.githubConnector.selectRepo("testuser/existing-app");
await po.githubConnector.selectBranch("main");
await po.githubConnector.clickConnectToRepoButton();
await po.githubConnector.snapshotConnectedRepo();
});
test("create and sync to existing repo - custom branch", async ({ po }) => {
// Clear any previous push events
await po.githubConnector.clearPushEvents();
await po.setUp();
await po.sendPrompt("tc=basic");
await po.getTitleBarAppNameButton().click();
await po.githubConnector.connect();
await po.githubConnector.getConnectToExistingRepoModeButton().click();
await po.githubConnector.selectRepo("testuser/existing-app");
await po.githubConnector.selectCustomBranch("new-branch");
await po.githubConnector.clickConnectToRepoButton();
// Sync to GitHub to trigger a push
await po.githubConnector.clickSyncToGithubButton();
await po.githubConnector.snapshotConnectedRepo();
// Verify the push was received for the correct custom branch
await po.githubConnector.verifyPushEvent({
repo: "existing-app",
branch: "new-branch",
operation: "create",
});
});
...@@ -64,21 +64,149 @@ class ProModesDialog { ...@@ -64,21 +64,149 @@ class ProModesDialog {
} }
} }
class GitHubConnector {
constructor(public page: Page) {}
async connect() {
await this.page.getByRole("button", { name: "Connect to GitHub" }).click();
}
getSetupYourGitHubRepoButton() {
return this.page.getByText("Set up your GitHub repo");
}
getCreateNewRepoModeButton() {
return this.page.getByRole("button", { name: "Create new repo" });
}
getConnectToExistingRepoModeButton() {
return this.page.getByRole("button", { name: "Connect to existing repo" });
}
async clickCreateRepoButton() {
await this.page.getByRole("button", { name: "Create Repo" }).click();
}
async fillCreateRepoName(name: string) {
await this.page.getByTestId("github-create-repo-name-input").fill(name);
}
async fillNewRepoBranchName(name: string) {
await this.page.getByTestId("github-new-repo-branch-input").fill(name);
}
async selectRepo(repo: string) {
await this.page.getByTestId("github-repo-select").click();
await this.page.getByRole("option", { name: repo }).click();
}
async selectBranch(branch: string) {
await this.page.getByTestId("github-branch-select").click();
await this.page.getByRole("option", { name: branch }).click();
}
async selectCustomBranch(branch: string) {
await this.page.getByTestId("github-branch-select").click();
await this.page
.getByRole("option", { name: "✏️ Type custom branch name" })
.click();
await this.page.getByTestId("github-custom-branch-input").click();
await this.page.getByTestId("github-custom-branch-input").fill(branch);
}
async clickConnectToRepoButton() {
await this.page.getByRole("button", { name: "Connect to repo" }).click();
}
async snapshotConnectedRepo() {
await expect(
this.page.getByTestId("github-connected-repo"),
).toMatchAriaSnapshot();
}
async snapshotSetupRepo() {
await expect(
this.page.getByTestId("github-setup-repo"),
).toMatchAriaSnapshot();
}
async snapshotUnconnectedRepo() {
await expect(
this.page.getByTestId("github-unconnected-repo"),
).toMatchAriaSnapshot();
}
async clickSyncToGithubButton() {
await this.page.getByRole("button", { name: "Sync to GitHub" }).click();
}
async clickDisconnectRepoButton() {
await this.page
.getByRole("button", { name: "Disconnect from repo" })
.click();
}
async clearPushEvents() {
const response = await this.page.request.post(
"http://localhost:3500/github/api/test/clear-push-events",
);
return await response.json();
}
async getPushEvents(repo?: string) {
const url = repo
? `http://localhost:3500/github/api/test/push-events?repo=${repo}`
: "http://localhost:3500/github/api/test/push-events";
const response = await this.page.request.get(url);
return await response.json();
}
async verifyPushEvent(expectedEvent: {
repo: string;
branch: string;
operation?: "push" | "create" | "delete";
}) {
const pushEvents = await this.getPushEvents(expectedEvent.repo);
const matchingEvent = pushEvents.find(
(event: any) =>
event.repo === expectedEvent.repo &&
event.branch === expectedEvent.branch &&
(!expectedEvent.operation ||
event.operation === expectedEvent.operation),
);
if (!matchingEvent) {
throw new Error(
`Expected push event not found. Expected: ${JSON.stringify(expectedEvent)}. ` +
`Actual events: ${JSON.stringify(pushEvents)}`,
);
}
return matchingEvent;
}
}
export class PageObject { export class PageObject {
private userDataDir: string; private userDataDir: string;
public githubConnector: GitHubConnector;
constructor( constructor(
public electronApp: ElectronApplication, public electronApp: ElectronApplication,
public page: Page, public page: Page,
{ userDataDir }: { userDataDir: string }, { userDataDir }: { userDataDir: string },
) { ) {
this.userDataDir = userDataDir; this.userDataDir = userDataDir;
this.githubConnector = new GitHubConnector(this.page);
}
private async baseSetup() {
await this.githubConnector.clearPushEvents();
} }
async setUp({ async setUp({
autoApprove = false, autoApprove = false,
nativeGit = false, nativeGit = false,
}: { autoApprove?: boolean; nativeGit?: boolean } = {}) { }: { autoApprove?: boolean; nativeGit?: boolean } = {}) {
await this.baseSetup();
await this.goToSettingsTab(); await this.goToSettingsTab();
if (autoApprove) { if (autoApprove) {
await this.toggleAutoApprove(); await this.toggleAutoApprove();
...@@ -93,16 +221,8 @@ export class PageObject { ...@@ -93,16 +221,8 @@ export class PageObject {
await this.selectTestModel(); await this.selectTestModel();
} }
async importApp(appDir: string) {
await this.page.getByRole("button", { name: "Import App" }).click();
await eph.stubDialog(this.electronApp, "showOpenDialog", {
filePaths: [path.join(__dirname, "..", "fixtures", "import-app", appDir)],
});
await this.page.getByRole("button", { name: "Select Folder" }).click();
await this.page.getByRole("button", { name: "Import" }).click();
}
async setUpDyadPro({ autoApprove = false }: { autoApprove?: boolean } = {}) { async setUpDyadPro({ autoApprove = false }: { autoApprove?: boolean } = {}) {
await this.baseSetup();
await this.goToSettingsTab(); await this.goToSettingsTab();
if (autoApprove) { if (autoApprove) {
await this.toggleAutoApprove(); await this.toggleAutoApprove();
...@@ -112,7 +232,6 @@ export class PageObject { ...@@ -112,7 +232,6 @@ export class PageObject {
} }
async setUpDyadProvider() { async setUpDyadProvider() {
// await page.getByRole('link', { name: 'Settings' }).click();
await this.page await this.page
.locator("div") .locator("div")
.filter({ hasText: /^DyadNeeds Setup$/ }) .filter({ hasText: /^DyadNeeds Setup$/ })
...@@ -123,12 +242,15 @@ export class PageObject { ...@@ -123,12 +242,15 @@ export class PageObject {
.getByRole("textbox", { name: "Set Dyad API Key" }) .getByRole("textbox", { name: "Set Dyad API Key" })
.fill("testdyadkey"); .fill("testdyadkey");
await this.page.getByRole("button", { name: "Save Key" }).click(); await this.page.getByRole("button", { name: "Save Key" }).click();
// await page.getByRole('link', { name: 'Apps' }).click(); }
// await page.getByTestId('home-chat-input-container').getByRole('button', { name: 'Pro' }).click();
// await page.getByRole('switch', { name: 'Turbo Edits' }).click(); async importApp(appDir: string) {
// await page.getByRole('switch', { name: 'Turbo Edits' }).click(); await this.page.getByRole("button", { name: "Import App" }).click();
// await page.locator('div').filter({ hasText: /^Import App$/ }).click(); await eph.stubDialog(this.electronApp, "showOpenDialog", {
// await page.getByRole('button', { name: 'Select Folder' }).press('Escape'); filePaths: [path.join(__dirname, "..", "fixtures", "import-app", appDir)],
});
await this.page.getByRole("button", { name: "Select Folder" }).click();
await this.page.getByRole("button", { name: "Import" }).click();
} }
async openContextFilesPicker() { async openContextFilesPicker() {
......
- paragraph: "Connected to GitHub Repo:"
- text: testuser/existing-app
- paragraph: "Branch: new-branch"
- button "Sync to GitHub"
- button "Disconnect from repo"
\ No newline at end of file
- paragraph: "Connected to GitHub Repo:"
- text: testuser/existing-app
- paragraph: "Branch: main"
- button "Sync to GitHub"
- button "Disconnect from repo"
\ No newline at end of file
- paragraph: "Connected to GitHub Repo:"
- text: testuser/test-new-repo
- paragraph: "Branch: new-branch"
- button "Sync to GitHub"
- button "Disconnect from repo"
\ No newline at end of file
- paragraph: "Connected to GitHub Repo:"
- text: testuser/test-new-repo
- paragraph: "Branch: main"
- button "Sync to GitHub"
- button "Disconnect from repo"
\ No newline at end of file
- paragraph: "Connected to GitHub Repo:"
- text: testuser/test-new-repo
- paragraph: "Branch: main"
- button "Sync to GitHub"
- button "Disconnect from repo"
- paragraph: Successfully pushed to GitHub!
\ No newline at end of file
- button "Set up your GitHub repo":
- img
- button "Create new repo"
- button "Connect to existing repo"
- text: Repository Name
- textbox: "[scrubbed]"
- paragraph: Repository name is available!
- text: Branch
- textbox "main"
- button "Create Repo"
\ No newline at end of file
- paragraph: "Connected to GitHub Repo:"
- text: testuser/test-new-repo
- paragraph: "Branch: main"
- button "Sync to GitHub"
- button "Disconnect from repo"
\ No newline at end of file
- paragraph: "Connected to GitHub Repo:"
- text: testuser/test-new-repo
- paragraph: "Branch: main"
- button "Sync to GitHub"
- button "Disconnect from repo"
- paragraph: Successfully pushed to GitHub!
\ No newline at end of file
...@@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; ...@@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import path from "node:path"; import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import { getDyadAppPath, getUserDataPath } from "../paths/paths"; import { getDyadAppPath, getUserDataPath } from "../paths/paths";
import { eq } from "drizzle-orm";
import log from "electron-log"; import log from "electron-log";
const logger = log.scope("db"); const logger = log.scope("db");
...@@ -87,14 +87,3 @@ try { ...@@ -87,14 +87,3 @@ try {
export const db = _db as any as BetterSQLite3Database<typeof schema> & { export const db = _db as any as BetterSQLite3Database<typeof schema> & {
$client: Database.Database; $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", { ...@@ -14,6 +14,7 @@ export const apps = sqliteTable("apps", {
.default(sql`(unixepoch())`), .default(sql`(unixepoch())`),
githubOrg: text("github_org"), githubOrg: text("github_org"),
githubRepo: text("github_repo"), githubRepo: text("github_repo"),
githubBranch: text("github_branch"),
supabaseProjectId: text("supabase_project_id"), supabaseProjectId: text("supabase_project_id"),
chatContext: text("chat_context", { mode: "json" }), chatContext: text("chat_context", { mode: "json" }),
}); });
......
...@@ -554,6 +554,36 @@ export class IpcClient { ...@@ -554,6 +554,36 @@ export class IpcClient {
// --- End GitHub Device Flow --- // --- End GitHub Device Flow ---
// --- GitHub Repo Management --- // --- 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( public async checkGithubRepoAvailable(
org: string, org: string,
repo: string, repo: string,
...@@ -568,25 +598,25 @@ export class IpcClient { ...@@ -568,25 +598,25 @@ export class IpcClient {
org: string, org: string,
repo: string, repo: string,
appId: number, appId: number,
branch?: string,
): Promise<void> { ): Promise<void> {
await this.ipcRenderer.invoke("github:create-repo", { await this.ipcRenderer.invoke("github:create-repo", {
org, org,
repo, repo,
appId, appId,
branch,
}); });
} }
// Sync (push) local repo to GitHub // Sync (push) local repo to GitHub
public async syncGithubRepo( public async syncGithubRepo(
appId: number, appId: number,
force?: boolean,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { return this.ipcRenderer.invoke("github:push", {
const result = await this.ipcRenderer.invoke("github:push", { appId }); appId,
return result as { success: boolean; error?: string }; force,
} catch (error) { });
showError(error);
throw error;
}
} }
public async disconnectGithubRepo(appId: number): Promise<void> { public async disconnectGithubRepo(appId: number): Promise<void> {
......
...@@ -70,6 +70,7 @@ export interface App { ...@@ -70,6 +70,7 @@ export interface App {
updatedAt: Date; updatedAt: Date;
githubOrg: string | null; githubOrg: string | null;
githubRepo: string | null; githubRepo: string | null;
githubBranch: string | null;
supabaseProjectId: string | null; supabaseProjectId: string | null;
supabaseProjectName: string | null; supabaseProjectName: string | null;
} }
......
...@@ -46,8 +46,11 @@ const validInvokeChannels = [ ...@@ -46,8 +46,11 @@ const validInvokeChannels = [
"nodejs-status", "nodejs-status",
"install-node", "install-node",
"github:start-flow", "github:start-flow",
"github:list-repos",
"github:get-repo-branches",
"github:is-repo-available", "github:is-repo-available",
"github:create-repo", "github:create-repo",
"github:connect-existing-repo",
"github:push", "github:push",
"github:disconnect", "github:disconnect",
"get-app-version", "get-app-version",
......
差异被折叠。
...@@ -2,6 +2,19 @@ import express from "express"; ...@@ -2,6 +2,19 @@ import express from "express";
import { createServer } from "http"; import { createServer } from "http";
import cors from "cors"; import cors from "cors";
import { createChatCompletionHandler } from "./chatCompletionHandler"; import { createChatCompletionHandler } from "./chatCompletionHandler";
import {
handleDeviceCode,
handleAccessToken,
handleUser,
handleUserEmails,
handleUserRepos,
handleRepo,
handleRepoBranches,
handleOrgRepos,
handleGitPush,
handleGetPushEvents,
handleClearPushEvents,
} from "./githubHandler";
// Create Express app // Create Express app
const app = express(); const app = express();
...@@ -179,6 +192,29 @@ app.get("/lmstudio/api/v0/models", (req, res) => { ...@@ -179,6 +192,29 @@ app.get("/lmstudio/api/v0/models", (req, res) => {
// Default test provider handler: // Default test provider handler:
app.post("/v1/chat/completions", createChatCompletionHandler(".")); app.post("/v1/chat/completions", createChatCompletionHandler("."));
// GitHub API Mock Endpoints
console.log("Setting up GitHub mock endpoints");
// GitHub OAuth Device Flow
app.post("/github/login/device/code", handleDeviceCode);
app.post("/github/login/oauth/access_token", handleAccessToken);
// GitHub API endpoints
app.get("/github/api/user", handleUser);
app.get("/github/api/user/emails", handleUserEmails);
app.get("/github/api/user/repos", handleUserRepos);
app.post("/github/api/user/repos", handleUserRepos);
app.get("/github/api/repos/:owner/:repo", handleRepo);
app.get("/github/api/repos/:owner/:repo/branches", handleRepoBranches);
app.post("/github/api/orgs/:org/repos", handleOrgRepos);
// GitHub test endpoints for verifying push operations
app.get("/github/api/test/push-events", handleGetPushEvents);
app.post("/github/api/test/clear-push-events", handleClearPushEvents);
// GitHub Git endpoints - intercept all paths with /github/git prefix
app.all("/github/git/*", handleGitPush);
// Start the server // Start the server
const server = createServer(app); const server = createServer(app);
server.listen(PORT, () => { server.listen(PORT, () => {
......
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
"@types/cors": "^2.8.18", "@types/cors": "^2.8.18",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.17.46", "@types/node": "^20.17.46",
"git-http-mock-server": "^2.0.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论