diff --git a/apps/web/components/vcs-split-button.tsx b/apps/web/components/vcs-split-button.tsx index ec3dc683d7..8d45b90c61 100644 --- a/apps/web/components/vcs-split-button.tsx +++ b/apps/web/components/vcs-split-button.tsx @@ -27,6 +27,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { useVcsDialogs } from "@/components/vcs/vcs-dialogs"; +import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; import type { ReactNode } from "react"; @@ -34,8 +35,10 @@ function determinePrimaryAction( uncommittedFileCount: number, aheadCount: number, behindCount: number, -): "commit" | "pr" | "rebase" { + hasOpenPR: boolean, +): "commit" | "push" | "pr" | "rebase" { if (uncommittedFileCount > 0) return "commit"; + if (aheadCount > 0 && hasOpenPR) return "push"; if (aheadCount > 0) return "pr"; if (behindCount > 0) return "rebase"; return "commit"; @@ -75,6 +78,16 @@ function buildPrConfig(aheadCount: number, openPRDialog: () => void): PrimaryBut }; } +function buildPushConfig(aheadCount: number, handlePush: () => void): PrimaryButtonConfig { + return { + icon: , + label: "Push", + badge: aheadCount > 0 ? aheadCount : null, + tooltip: `Push ${aheadCount} commit${aheadCount !== 1 ? "s" : ""} to remote`, + onClick: handlePush, + }; +} + function buildRebaseConfig( behindCount: number, baseBranch: string | undefined, @@ -90,13 +103,14 @@ function buildRebaseConfig( } type PrimaryConfigArgs = { - primaryAction: "commit" | "pr" | "rebase"; + primaryAction: "commit" | "push" | "pr" | "rebase"; uncommittedFileCount: number; aheadCount: number; behindCount: number; baseBranch: string | undefined; openCommitDialog: () => void; openPRDialog: () => void; + handlePush: () => void; handleRebase: () => void; }; @@ -108,8 +122,10 @@ function buildPrimaryButtonConfig({ baseBranch, openCommitDialog, openPRDialog, + handlePush, handleRebase, }: PrimaryConfigArgs): PrimaryButtonConfig { + if (primaryAction === "push") return buildPushConfig(aheadCount, handlePush); if (primaryAction === "pr") return buildPrConfig(aheadCount, openPRDialog); if (primaryAction === "rebase") return buildRebaseConfig(behindCount, baseBranch, handleRebase); return buildCommitConfig(uncommittedFileCount, openCommitDialog); @@ -205,24 +221,8 @@ type VcsSplitButtonProps = { baseBranch?: string; }; -const VcsSplitButton = memo(function VcsSplitButton({ - sessionId, - baseBranch, -}: VcsSplitButtonProps) { +function useGitActions(git: ReturnType, baseBranch?: string) { const gitWithFeedback = useGitWithFeedback(); - const git = useSessionGit(sessionId); - const { openCommitDialog, openPRDialog } = useVcsDialogs(); - - const currentBranch = git.branch; - const remoteBranch = git.remoteBranch; - const hasMatchingUpstream = - remoteBranch && currentBranch && remoteBranch === `origin/${currentBranch}`; - - const uncommittedFileCount = git.allFiles.length; - const aheadCount = git.ahead; - const behindCount = git.behind; - const isDisabled = git.isLoading || !sessionId; - const isGitLoading = git.isLoading; const handlePull = useCallback(() => { gitWithFeedback(() => git.pull(), "Pull"); @@ -245,7 +245,35 @@ const VcsSplitButton = memo(function VcsSplitButton({ gitWithFeedback(() => git.merge(targetBranch), "Merge"); }, [gitWithFeedback, git, baseBranch]); - const primaryAction = determinePrimaryAction(uncommittedFileCount, aheadCount, behindCount); + return { handlePull, handlePush, handleRebase, handleMerge }; +} + +const VcsSplitButton = memo(function VcsSplitButton({ + sessionId, + baseBranch, +}: VcsSplitButtonProps) { + const git = useSessionGit(sessionId); + const { openCommitDialog, openPRDialog } = useVcsDialogs(); + const activePR = useActiveTaskPR(); + const hasOpenPR = activePR?.state === "open"; + const { handlePull, handlePush, handleRebase, handleMerge } = useGitActions(git, baseBranch); + + const currentBranch = git.branch; + const remoteBranch = git.remoteBranch; + const hasMatchingUpstream = + remoteBranch && currentBranch && remoteBranch === `origin/${currentBranch}`; + const uncommittedFileCount = git.allFiles.length; + const aheadCount = git.ahead; + const behindCount = git.behind; + const isDisabled = git.isLoading || !sessionId; + const isGitLoading = git.isLoading; + + const primaryAction = determinePrimaryAction( + uncommittedFileCount, + aheadCount, + behindCount, + hasOpenPR, + ); const primaryButtonConfig = buildPrimaryButtonConfig({ primaryAction, uncommittedFileCount, @@ -254,6 +282,7 @@ const VcsSplitButton = memo(function VcsSplitButton({ baseBranch, openCommitDialog, openPRDialog, + handlePush: () => handlePush(false), handleRebase, }); @@ -267,6 +296,7 @@ const VcsSplitButton = memo(function VcsSplitButton({ className="rounded-none border-0 cursor-pointer" onClick={primaryButtonConfig.onClick} disabled={isDisabled} + data-testid={`vcs-primary-${primaryAction}`} > {primaryButtonConfig.icon} {primaryButtonConfig.label} diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 8ec86bf62a..81ea6b93ca 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -281,6 +281,14 @@ export class SessionPage { return this.page.getByTestId("pr-topbar-button"); } + /** VCS split button primary action. Pass action to match a specific state. */ + vcsPrimaryButton(action?: "commit" | "push" | "pr" | "rebase"): Locator { + if (action) { + return this.page.getByTestId(`vcs-primary-${action}`); + } + return this.page.locator('[data-testid^="vcs-primary-"]'); + } + /** PR detail panel (auto-shown when task has an associated PR). */ prDetailPanel(): Locator { return this.page.getByTestId("pr-detail-panel"); diff --git a/apps/web/e2e/tests/pr/vcs-button-context.spec.ts b/apps/web/e2e/tests/pr/vcs-button-context.spec.ts new file mode 100644 index 0000000000..e281bd3a21 --- /dev/null +++ b/apps/web/e2e/tests/pr/vcs-button-context.spec.ts @@ -0,0 +1,297 @@ +import { test, expect } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { KanbanPage } from "../../pages/kanban-page"; +import { SessionPage } from "../../pages/session-page"; +import type { Page } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { execSync } from "node:child_process"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +class GitHelper { + constructor( + private repoDir: string, + private env: NodeJS.ProcessEnv, + ) {} + + exec(cmd: string): string { + const lockPath = path.join(this.repoDir, ".git", "index.lock"); + for (let attempt = 0; attempt < 3; attempt++) { + if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath); + try { + return execSync(cmd, { cwd: this.repoDir, env: this.env, encoding: "utf8" }); + } catch (err) { + const msg = (err as Error).message ?? ""; + if (msg.includes("index.lock") && attempt < 2) { + execSync("sleep 0.2"); + continue; + } + throw err; + } + } + throw new Error(`git exec failed after 3 attempts: ${cmd}`); + } + + createFile(name: string, content: string) { + fs.writeFileSync(path.join(this.repoDir, name), content); + } + + /** Set up a bare remote so the local branch can track ahead/behind. */ + setupRemote(tmpDir: string): void { + const bareDir = path.join(tmpDir, "repos", "e2e-repo-bare.git"); + execSync(`git clone --bare "${this.repoDir}" "${bareDir}"`, { env: this.env }); + this.exec(`git remote add origin "${bareDir}"`); + this.exec("git fetch origin"); + this.exec("git branch --set-upstream-to=origin/main main"); + } + + commitFile(name: string, content: string, message: string): void { + this.createFile(name, content); + this.exec("git add -A"); + this.exec(`git commit -m "${message}"`); + } +} + +async function createStandardProfile(apiClient: ApiClient, name: string) { + const { agents } = await apiClient.listAgents(); + const agentId = agents[0]?.id; + if (!agentId) { + throw new Error(`E2E setup failed: no agent available for profile "${name}"`); + } + return apiClient.createAgentProfile(agentId, name, { + model: "mock-fast", + auto_approve: true, + cli_passthrough: false, + }); +} + +async function openTaskSession(page: Page, title: string): Promise { + const kanban = new KanbanPage(page); + await kanban.goto(); + + const card = kanban.taskCardByTitle(title); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(page).toHaveURL(/\/t\//, { timeout: 15_000 }); + + const session = new SessionPage(page); + await session.waitForLoad(); + return session; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe("VCS split button context", () => { + test("shows Push when PR exists and commits are ahead", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(120_000); + + const profile = await createStandardProfile(apiClient, "VCS Push Profile"); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "VCS Push Button Test", + profile.id, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + const session = await openTaskSession(testPage, "VCS Push Button Test"); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // Set up git with a remote so we can get "ahead" status + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + const gitEnv = { + ...process.env, + HOME: backend.tmpDir, + GIT_AUTHOR_NAME: "E2E Test", + GIT_AUTHOR_EMAIL: "e2e@test.local", + GIT_COMMITTER_NAME: "E2E Test", + GIT_COMMITTER_EMAIL: "e2e@test.local", + }; + const git = new GitHelper(repoDir, gitEnv); + git.setupRemote(backend.tmpDir); + + // Make a local commit → now 1 ahead of origin/main + git.commitFile("push-test.txt", "push content", "feat: push test commit"); + + // Before associating a PR, the button should show "Create PR" + // (ahead > 0, no PR) + await expect(session.vcsPrimaryButton("pr")).toBeVisible({ timeout: 30_000 }); + + // Now associate an open PR with the task + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + await apiClient.mockGitHubAssociateTaskPR({ + task_id: task.id, + owner: "testorg", + repo: "testrepo", + pr_number: 50, + pr_url: "https://github.com/testorg/testrepo/pull/50", + pr_title: "Feature branch", + head_branch: "main", + base_branch: "develop", + author_login: "test-user", + additions: 10, + deletions: 2, + }); + + // Wait for the PR topbar button to appear (confirms PR is in store) + await expect(session.prTopbarButton()).toBeVisible({ timeout: 30_000 }); + + // The VCS button should now show "Push" instead of "Create PR" + await expect(session.vcsPrimaryButton("push")).toBeVisible({ timeout: 15_000 }); + await expect(session.vcsPrimaryButton("push")).toContainText("Push"); + }); + + test("shows Create PR when ahead but no PR exists", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(120_000); + + const profile = await createStandardProfile(apiClient, "VCS PR Profile"); + + await apiClient.createTaskWithAgent(seedData.workspaceId, "VCS PR Button Test", profile.id, { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + const session = await openTaskSession(testPage, "VCS PR Button Test"); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // Set up git with a remote + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + const gitEnv = { + ...process.env, + HOME: backend.tmpDir, + GIT_AUTHOR_NAME: "E2E Test", + GIT_AUTHOR_EMAIL: "e2e@test.local", + GIT_COMMITTER_NAME: "E2E Test", + GIT_COMMITTER_EMAIL: "e2e@test.local", + }; + const git = new GitHelper(repoDir, gitEnv); + + // Only set up remote if not already configured + try { + git.exec("git remote get-url origin"); + } catch { + git.setupRemote(backend.tmpDir); + } + + // Push current state to origin, then make a new commit to be ahead + git.exec("git push origin main"); + git.commitFile("pr-test.txt", "pr content", "feat: pr test commit"); + + // No PR associated → should show "Create PR" + await expect(session.vcsPrimaryButton("pr")).toBeVisible({ timeout: 30_000 }); + await expect(session.vcsPrimaryButton("pr")).toContainText("Create PR"); + }); + + test("shows Commit when there are uncommitted files even with open PR", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(120_000); + + const profile = await createStandardProfile(apiClient, "VCS Commit Profile"); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "VCS Commit Priority Test", + profile.id, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + const session = await openTaskSession(testPage, "VCS Commit Priority Test"); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // Associate an open PR + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + await apiClient.mockGitHubAssociateTaskPR({ + task_id: task.id, + owner: "testorg", + repo: "testrepo", + pr_number: 55, + pr_url: "https://github.com/testorg/testrepo/pull/55", + pr_title: "Commit Priority PR", + head_branch: "main", + base_branch: "develop", + author_login: "test-user", + additions: 5, + deletions: 1, + }); + + await expect(session.prTopbarButton()).toBeVisible({ timeout: 30_000 }); + + // Create an uncommitted file + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + const git = new GitHelper(repoDir, { + ...process.env, + HOME: backend.tmpDir, + GIT_AUTHOR_NAME: "E2E Test", + GIT_AUTHOR_EMAIL: "e2e@test.local", + GIT_COMMITTER_NAME: "E2E Test", + GIT_COMMITTER_EMAIL: "e2e@test.local", + }); + git.createFile("uncommitted.txt", "uncommitted content"); + + // Even with open PR, uncommitted files → "Commit" takes priority + await expect(session.vcsPrimaryButton("commit")).toBeVisible({ timeout: 30_000 }); + await expect(session.vcsPrimaryButton("commit")).toContainText("Commit"); + }); + + test("defaults to Commit when no changes and no PR", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + const profile = await createStandardProfile(apiClient, "VCS Default Profile"); + + await apiClient.createTaskWithAgent( + seedData.workspaceId, + "VCS Default Button Test", + profile.id, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + const session = await openTaskSession(testPage, "VCS Default Button Test"); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // No changes, no PR → default is "Commit" + await expect(session.vcsPrimaryButton("commit")).toBeVisible({ timeout: 15_000 }); + await expect(session.vcsPrimaryButton("commit")).toContainText("Commit"); + }); +});