From 123b2f6eed911836d10f5aad706f7d2a73cccad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:47:58 +0000 Subject: [PATCH] feat: start task from GitHub URL (#365) * feat(backend): accept github_url in task creation and clone on launch Add github_url field to task creation DTO and propagate through HTTP/WS handlers and service layer. When a task is created with a GitHub URL instead of a local repository, the service resolves (or creates) the repository record via FindOrCreateRepository and triggers a clone/fetch via repoclone.Cloner during executor pre-flight. Wire RepoCloner into the orchestrator executor so remote repositories are fetched before the agent process starts. On resume, RepoUpdater performs a fetch to ensure the worktree is up-to-date. * feat(backend): add GitHub branches endpoint for remote repos Add ListRepoBranches to the GitHub client interface (GHClient, PATClient, NoopClient, MockClient) and expose it via GET /api/v1/github/repos/:owner/:repo/branches. The mock controller gains POST /api/v1/github/mock/branches for seeding branch data in E2E tests. * feat(web): add GitHub URL input to task create dialog Add a toggle in the task create dialog that switches between the existing repository selector and a free-text GitHub URL input. When a valid GitHub URL is entered (owner/repo parsed), branches are fetched from the new backend endpoint and auto-selected. The dialog state, effects, validation, and submit logic are extended to support the github_url flow alongside the existing repository flow. * test(e2e): add tests for task creation from GitHub URL Add E2E tests verifying: - Full flow: toggle to GitHub URL mode, enter URL, create task, verify agent runs and completes in session view - Toggle behavior: switching between GitHub URL and repository selector modes updates UI correctly * fix(web): dynamic URL input sizing and extract RepoSourceInput Replace fixed-width GitHub URL input with size-based auto-sizing (matching InlineTaskName pattern). Extract RepoSourceInput component from DialogHeaderContent to stay within cyclomatic complexity limits. Add testId prop to Combobox and wire it to ExecutorProfileSelector for E2E test targeting. * test(e2e): add worktree executor test and fix GitHub URL repo seeding Update GitHub URL E2E tests to pre-seed a repository with GitHub provider info pointing to the local git repo, avoiding actual clone attempts during task creation. Add test for GitHub URL task creation with worktree executor type, verifying the full flow including executor profile selection. Extend ApiClient with listExecutors and provider fields on createRepository for test seeding. * fix(backend): use protocol-aware clone URL for provider-backed repos The ensureRepoCloned function was hardcoding HTTPS clone URLs via repositoryCloneURL(), but the user's git setup may require SSH. Add BuildCloneURL to the RepoCloner interface so the executor uses the Cloner's configured protocol (SSH/HTTPS) instead of always generating HTTPS URLs. Falls back to HTTPS if BuildCloneURL fails. This fixes workspace loading issues (terminal, file tree) for cloned repos when the user has SSH-only authentication configured. * test(e2e): enhance GitHub URL test with terminal and changes panel verification * feat(web): add inline validation for GitHub URL input - Show inline error for invalid URL format (not matching github.com/owner/repo) - Show inline error when repository is not found or not accessible - Mock client returns error for unknown repos (matches real GitHub API) - E2E tests for invalid URL, nonexistent repo, and error-clearing on valid input * fix(web): fix GitHub URL branch auto-select and E2E test stability - Pick branch by name (main/master) for GitHub URL mode instead of using selectPreferredBranch which returns origin-prefixed names - Add test timeout for worktree executor test - Use shorter filename in terminal test to avoid xterm line-wrap issues * fix(web): fix GitHub URL dialog polish and wrong-repo bug UI fixes: - Align toggle button text with dropdown text content (pl-3) - Match GitHub URL input height to dropdown trigger (min-h-9) - Position error text absolutely to prevent layout shift - Remove redundant toast on repo-not-found (inline error suffices) Bug fix: - Prevent repo auto-select from overriding GitHub URL mode by guarding useRepositoryAutoSelectEffect with useGitHubUrl - Reorder getRepositoriesPayload to check useGitHubUrl first, preventing stale repositoryId from winning over the GitHub URL Add E2E test verifying correct repo usage across consecutive tasks. * fix(web): fix dialog height mismatch and error text overlap - Use exact h-9 on GitHub URL input to match dropdown trigger height - Move error text from absolute-positioned overlay to inline next to the toggle button, preventing overlap while keeping layout stable * fix(web): make GitHub URL input auto-size to content Reduce minimum size from 30 to 20 and shorten placeholder so the "/ task-name" sits closer to the URL text, matching the dropdown's compact w-auto behavior. * fix(web): polish GitHub URL input sizing, floating error, and placeholder color - Match input height (h-7) and padding (px-2) to Combobox trigger button - Size input width to exactly fit placeholder text - Show validation error as floating popover below input instead of inline - Keep placeholder text grey even when input has error state - Extract useGitHubUrlState hook to fix max-lines-per-function lint * fix(backend): return proper HTTP status codes from GitHub branches endpoint Add GitHubAPIError type to distinguish upstream GitHub API status codes. The branches endpoint now returns 404 (not 500) for non-existent repos, which the logging middleware correctly logs as DEBUG instead of ERROR. * feat(web): show error toast when a task session fails to start Add session failure notification plumbing: WS handler detects FAILED state with error_message, pushes to Zustand store, and a global bridge component shows an error toast via the ToastProvider. Also adds data-testid attributes to toast elements and types error_message on the session state changed payload. * style: fix prettier formatting in task-create-dialog and related files * fix(web): extract ToastList component to fix max-lines-per-function lint * fix: address code review feedback for GitHub URL task creation - Preserve 403 status instead of collapsing to 401 in branches endpoint - Use clone-generic error instead of remote-docker-specific in ensureRepoCloned - Trim whitespace in GitHub URL validation (backend + frontend) - Add GitHubURL field to wsUpdateTask path - Fix DefaultBranch mapping to prefer repoInput.DefaultBranch over BaseBranch - Clear githubBranchesLoading on early-return paths to prevent stuck state - Gate GitHub branch loading by mode to avoid false loading states - Encode owner/repo in API path for special characters - Add aria-label, aria-invalid, aria-errormessage for accessibility * fix(e2e): improve diff-update test stability with fresh locator and longer timeout * fix(backend): add --paginate flag to gh api branches call for repos with >100 branches * fix: address second round of code review feedback - Add pagination support to PATClient.ListRepoBranches for repos with >100 branches - Validate owner/repo in mock controller addBranches endpoint - Use aria-describedby instead of aria-errormessage for broader AT support - Add aria-live region to toast container for screen reader announcements - Assert absence of FIRST_MODIFICATION in diff-update E2E test * fix(backend): downgrade HTTP request logger from error to warn for 4xx/5xx responses * fix: address third round of code review feedback - Validate repositoryID/localPath in UpdateRepositoryLocalPath - Copy slices in mock client to prevent external mutation - Extract GitHub header constants and shared setter in PATClient - Redact clone URL from logs to prevent credential leaks - Guard against persisting empty localPath after clone - Show fallback toast message when FAILED state has no error_message --- .gitignore | 3 + apps/backend/cmd/kandev/orchestrator.go | 18 + .../backend/internal/common/httpmw/logging.go | 2 - apps/backend/internal/github/client.go | 3 + apps/backend/internal/github/controller.go | 25 ++ apps/backend/internal/github/gh_client.go | 20 + apps/backend/internal/github/mock_client.go | 34 ++ .../internal/github/mock_controller.go | 19 + apps/backend/internal/github/models.go | 5 + apps/backend/internal/github/noop_client.go | 4 + apps/backend/internal/github/pat_client.go | 103 ++++- apps/backend/internal/github/service.go | 8 + .../orchestrator/executor/executor.go | 23 ++ .../orchestrator/executor/executor_resume.go | 58 +++ apps/backend/internal/orchestrator/service.go | 7 + apps/backend/internal/repoclone/clone.go | 6 + apps/backend/internal/task/dto/requests.go | 1 + .../internal/task/handlers/task_handlers.go | 1 + .../task/handlers/task_http_handlers.go | 6 +- .../task/handlers/task_ws_handlers.go | 6 +- .../internal/task/service/service_requests.go | 1 + .../internal/task/service/service_tasks.go | 68 +++- apps/web/app/layout.tsx | 2 + apps/web/components/combobox.tsx | 3 + .../session-failure-toast-bridge.tsx | 9 + .../components/task-create-dialog-effects.ts | 96 ++++- .../task-create-dialog-form-body.tsx | 11 +- .../components/task-create-dialog-helpers.ts | 3 +- .../task-create-dialog-selectors.tsx | 1 + .../components/task-create-dialog-state.ts | 76 +++- .../components/task-create-dialog-submit.tsx | 21 +- .../components/task-create-dialog-types.ts | 10 + apps/web/components/task-create-dialog.tsx | 142 +++++-- apps/web/components/toast-provider.tsx | 68 ++-- apps/web/e2e/helpers/api-client.ts | 34 +- .../e2e/tests/create-task-github-url.spec.ts | 359 ++++++++++++++++++ apps/web/e2e/tests/diff-update.spec.ts | 23 +- apps/web/hooks/use-session-failure-toast.ts | 25 ++ apps/web/lib/api/domains/github-api.ts | 8 + apps/web/lib/api/domains/kanban-api.ts | 1 + apps/web/lib/state/slices/ui/types.ts | 8 + apps/web/lib/state/slices/ui/ui-slice.ts | 5 + apps/web/lib/state/store.ts | 4 + apps/web/lib/types/backend.ts | 1 + apps/web/lib/ws/handlers/agent-session.ts | 10 + 45 files changed, 1239 insertions(+), 102 deletions(-) create mode 100644 apps/web/components/session-failure-toast-bridge.tsx create mode 100644 apps/web/e2e/tests/create-task-github-url.spec.ts create mode 100644 apps/web/hooks/use-session-failure-toast.ts diff --git a/.gitignore b/.gitignore index dfed567b82..aa8c186f22 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,6 @@ apps/landing/next-env.d.ts # agent message logs raw-*.jsonl normalized-*.jsonl + +# Serena MCP +.serena/* diff --git a/apps/backend/cmd/kandev/orchestrator.go b/apps/backend/cmd/kandev/orchestrator.go index 71e680fb88..b8d458f758 100644 --- a/apps/backend/cmd/kandev/orchestrator.go +++ b/apps/backend/cmd/kandev/orchestrator.go @@ -84,6 +84,9 @@ func provideOrchestrator( taskSvc: taskSvc, logger: log, }) + + // Wire repo cloner into executor for provider-backed repos with no local path + orchestratorSvc.SetRepoCloner(repoCloner, &repoLocalPathUpdater{svc: taskSvc}) } return orchestratorSvc, msgCreator, nil @@ -192,6 +195,21 @@ func (a *reviewTaskCreatorAdapter) CreateReviewTask(ctx context.Context, req *or }) } +// repoLocalPathUpdater adapts the task service's UpdateRepository to the executor.RepoUpdater interface. +type repoLocalPathUpdater struct { + svc *taskservice.Service +} + +func (u *repoLocalPathUpdater) UpdateRepositoryLocalPath(ctx context.Context, repositoryID, localPath string) error { + if repositoryID == "" || localPath == "" { + return fmt.Errorf("UpdateRepositoryLocalPath: repositoryID and localPath must be non-empty") + } + _, err := u.svc.UpdateRepository(ctx, repositoryID, &taskservice.UpdateRepositoryRequest{ + LocalPath: &localPath, + }) + return err +} + // repositoryResolverAdapter resolves GitHub repos by cloning + finding/creating DB records. type repositoryResolverAdapter struct { cloner *repoclone.Cloner diff --git a/apps/backend/internal/common/httpmw/logging.go b/apps/backend/internal/common/httpmw/logging.go index 7944187721..e905d3ed41 100644 --- a/apps/backend/internal/common/httpmw/logging.go +++ b/apps/backend/internal/common/httpmw/logging.go @@ -35,8 +35,6 @@ func RequestLogger(log *logger.Logger, serverName string) gin.HandlerFunc { zap.Int("bytes", size), } switch { - case status >= 500: - log.Error("http", fields...) case status >= 400 && status != 404: log.Warn("http", fields...) default: diff --git a/apps/backend/internal/github/client.go b/apps/backend/internal/github/client.go index d8506b1b97..b3eb92ece3 100644 --- a/apps/backend/internal/github/client.go +++ b/apps/backend/internal/github/client.go @@ -57,4 +57,7 @@ type Client interface { // SubmitReview submits a review on a pull request. // event is one of "APPROVE", "COMMENT", "REQUEST_CHANGES". SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error + + // ListRepoBranches lists branches for a repository. + ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) } diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go index 71cc40750a..5f40621798 100644 --- a/apps/backend/internal/github/controller.go +++ b/apps/backend/internal/github/controller.go @@ -1,6 +1,7 @@ package github import ( + "errors" "net/http" "strconv" "strings" @@ -46,6 +47,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/orgs", c.httpListUserOrgs) api.GET("/repos/search", c.httpSearchRepos) + api.GET("/repos/:owner/:repo/branches", c.httpListRepoBranches) api.GET("/stats", c.httpGetStats) } @@ -260,6 +262,29 @@ func (c *Controller) httpSearchRepos(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"repos": repos}) } +func (c *Controller) httpListRepoBranches(ctx *gin.Context) { + owner := ctx.Param("owner") + repo := ctx.Param("repo") + branches, err := c.service.ListRepoBranches(ctx.Request.Context(), owner, repo) + if err != nil { + status := http.StatusInternalServerError + var apiErr *GitHubAPIError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case http.StatusNotFound: + status = http.StatusNotFound + case http.StatusUnauthorized: + status = http.StatusUnauthorized + case http.StatusForbidden: + status = http.StatusForbidden + } + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"branches": branches}) +} + func (c *Controller) httpGetStats(ctx *gin.Context) { req := &PRStatsRequest{ WorkspaceID: ctx.Query("workspace_id"), diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index 4782fcb74e..ab7fd282bc 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -391,6 +391,26 @@ func (c *GHClient) SubmitReview(ctx context.Context, owner, repo string, number return nil } +func (c *GHClient) ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) { + out, err := c.run(ctx, "api", + fmt.Sprintf("repos/%s/%s/branches", owner, repo), + "-X", "GET", + "-f", "per_page=100", + "--paginate", + "--jq", ".[].name") + if err != nil { + return nil, fmt.Errorf("list repo branches: %w", err) + } + var branches []RepoBranch + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + name := strings.TrimSpace(line) + if name != "" { + branches = append(branches, RepoBranch{Name: name}) + } + } + return branches, nil +} + // run executes a gh CLI command and returns its stdout output. // Stderr is captured separately to avoid contaminating JSON output. func (c *GHClient) run(ctx context.Context, args ...string) (string, error) { diff --git a/apps/backend/internal/github/mock_client.go b/apps/backend/internal/github/mock_client.go index 146fabb1d4..22214052a3 100644 --- a/apps/backend/internal/github/mock_client.go +++ b/apps/backend/internal/github/mock_client.go @@ -40,6 +40,12 @@ type submittedReview struct { Body string `json:"body"` } +// repoKey is a composite key for per-repo lookups by owner/repo. +type repoKey struct { + Owner string + Repo string +} + // MockClient implements Client with in-memory configurable data for E2E testing. // All data is protected by a sync.RWMutex for thread safety. type MockClient struct { @@ -49,6 +55,7 @@ type MockClient struct { prsByBranch map[branchKey]*PR orgs []GitHubOrg repos map[string][]GitHubRepo + branches map[repoKey][]RepoBranch reviews map[prKey][]PRReview comments map[prKey][]PRComment checks map[checkKey][]CheckRun @@ -64,6 +71,7 @@ func NewMockClient() *MockClient { prs: make(map[prKey]*PR), prsByBranch: make(map[branchKey]*PR), repos: make(map[string][]GitHubRepo), + branches: make(map[repoKey][]RepoBranch), reviews: make(map[prKey][]PRReview), comments: make(map[prKey][]PRComment), checks: make(map[checkKey][]CheckRun), @@ -194,6 +202,22 @@ func (m *MockClient) ListPRCommits(_ context.Context, owner, repo string, number return m.commits[prKey{owner, repo, number}], nil } +func (m *MockClient) ListRepoBranches(_ context.Context, owner, repo string) ([]RepoBranch, error) { + m.mu.RLock() + defer m.mu.RUnlock() + branches, ok := m.branches[repoKey{owner, repo}] + if !ok { + return nil, &GitHubAPIError{ + StatusCode: 404, + Endpoint: fmt.Sprintf("/repos/%s/%s/branches", owner, repo), + Body: fmt.Sprintf("repository %s/%s not found", owner, repo), + } + } + out := make([]RepoBranch, len(branches)) + copy(out, branches) + return out, nil +} + func (m *MockClient) SubmitReview(_ context.Context, owner, repo string, number int, event, body string) error { m.mu.Lock() defer m.mu.Unlock() @@ -229,6 +253,15 @@ func (m *MockClient) AddOrgs(orgs []GitHubOrg) { m.orgs = append(m.orgs, orgs...) } +// AddBranches sets branches for a repository. +func (m *MockClient) AddBranches(owner, repo string, branches []RepoBranch) { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]RepoBranch, len(branches)) + copy(cp, branches) + m.branches[repoKey{owner, repo}] = cp +} + // AddRepos appends repositories for an organization. func (m *MockClient) AddRepos(org string, repos []GitHubRepo) { m.mu.Lock() @@ -285,6 +318,7 @@ func (m *MockClient) Reset() { m.prsByBranch = make(map[branchKey]*PR) m.orgs = nil m.repos = make(map[string][]GitHubRepo) + m.branches = make(map[repoKey][]RepoBranch) m.reviews = make(map[prKey][]PRReview) m.comments = make(map[prKey][]PRComment) m.checks = make(map[checkKey][]CheckRun) diff --git a/apps/backend/internal/github/mock_controller.go b/apps/backend/internal/github/mock_controller.go index 22d2a729c5..f8c67df579 100644 --- a/apps/backend/internal/github/mock_controller.go +++ b/apps/backend/internal/github/mock_controller.go @@ -34,6 +34,7 @@ func (c *MockController) RegisterRoutes(router *gin.Engine) { api.POST("/checks", c.addCheckRuns) api.POST("/files", c.addPRFiles) api.POST("/commits", c.addPRCommits) + api.POST("/branches", c.addBranches) api.POST("/task-prs", c.associateTaskPR) api.DELETE("/reset", c.reset) } @@ -164,6 +165,24 @@ func (c *MockController) addPRCommits(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"added": len(req.Commits)}) } +func (c *MockController) addBranches(ctx *gin.Context) { + var req struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Branches []RepoBranch `json:"branches"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if req.Owner == "" || req.Repo == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "owner and repo are required"}) + return + } + c.mock.AddBranches(req.Owner, req.Repo, req.Branches) + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Branches)}) +} + // associateTaskPR directly creates a github_task_prs record for E2E testing. func (c *MockController) associateTaskPR(ctx *gin.Context) { var req struct { diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index 85ecb35cd9..da9ebee0d3 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -188,6 +188,11 @@ type GitHubRepo struct { Private bool `json:"private"` } +// RepoBranch represents a branch in a GitHub repository. +type RepoBranch struct { + Name string `json:"name"` +} + // GitHubStatus represents GitHub connection status. type GitHubStatus struct { Authenticated bool `json:"authenticated"` diff --git a/apps/backend/internal/github/noop_client.go b/apps/backend/internal/github/noop_client.go index c8e6b54d38..cf67c357f6 100644 --- a/apps/backend/internal/github/noop_client.go +++ b/apps/backend/internal/github/noop_client.go @@ -69,6 +69,10 @@ func (c *NoopClient) ListPRCommits(context.Context, string, string, int) ([]PRCo return nil, ErrNoClient } +func (c *NoopClient) ListRepoBranches(context.Context, string, string) ([]RepoBranch, error) { + return nil, ErrNoClient +} + func (c *NoopClient) SubmitReview(context.Context, string, string, int, string, string) error { return ErrNoClient } diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go index bcbefd4a77..7c918f644f 100644 --- a/apps/backend/internal/github/pat_client.go +++ b/apps/backend/internal/github/pat_client.go @@ -12,7 +12,22 @@ import ( "time" ) -const githubAPIBase = "https://api.github.com" +const ( + githubAPIBase = "https://api.github.com" + githubAccept = "application/vnd.github+json" + githubAPIVersion = "2022-11-28" +) + +// GitHubAPIError represents an error response from the GitHub API with a status code. +type GitHubAPIError struct { + StatusCode int + Endpoint string + Body string +} + +func (e *GitHubAPIError) Error() string { + return fmt.Sprintf("GitHub API %s returned %d: %s", e.Endpoint, e.StatusCode, e.Body) +} // PATClient implements Client using a GitHub Personal Access Token. type PATClient struct { @@ -31,6 +46,13 @@ func NewPATClient(token string) *PATClient { } } +// setGitHubHeaders sets the common Authorization, Accept, and API version headers. +func (c *PATClient) setGitHubHeaders(req *http.Request) { + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Accept", githubAccept) + req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) +} + func (c *PATClient) IsAuthenticated(ctx context.Context) (bool, error) { _, err := c.GetAuthenticatedUser(ctx) return err == nil, nil @@ -252,6 +274,25 @@ func (c *PATClient) ListPRCommits(ctx context.Context, owner, repo string, numbe return convertRawPRCommits(raw), nil } +func (c *PATClient) ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) { + var branches []RepoBranch + endpoint := fmt.Sprintf("/repos/%s/%s/branches?per_page=100", owner, repo) + for endpoint != "" { + var page []struct { + Name string `json:"name"` + } + nextLink, err := c.getPaginated(ctx, endpoint, &page) + if err != nil { + return nil, fmt.Errorf("list repo branches: %w", err) + } + for _, b := range page { + branches = append(branches, RepoBranch{Name: b.Name}) + } + endpoint = nextLink + } + return branches, nil +} + func (c *PATClient) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error { endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, number) payload := map[string]string{"event": event} @@ -271,10 +312,8 @@ func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) erro if err != nil { return err } - req.Header.Set("Authorization", "token "+c.token) - req.Header.Set("Accept", "application/vnd.github+json") + c.setGitHubHeaders(req) req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") resp, err := c.httpClient.Do(req) if err != nil { @@ -295,9 +334,7 @@ func (c *PATClient) get(ctx context.Context, endpoint string, result interface{} if err != nil { return err } - req.Header.Set("Authorization", "token "+c.token) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + c.setGitHubHeaders(req) resp, err := c.httpClient.Do(req) if err != nil { @@ -307,11 +344,61 @@ func (c *PATClient) get(ctx context.Context, endpoint string, result interface{} if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return fmt.Errorf("GitHub API %s returned %d: %s", endpoint, resp.StatusCode, string(body)) + return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)} } return json.NewDecoder(resp.Body).Decode(result) } +// getPaginated is like get but also returns the "next" link from the Link header, if any. +func (c *PATClient) getPaginated(ctx context.Context, endpoint string, result interface{}) (string, error) { + u := githubAPIBase + endpoint + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", err + } + c.setGitHubHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("request %s: %w", endpoint, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)} + } + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return "", err + } + return parseNextLink(resp.Header.Get("Link")), nil +} + +// parseNextLink extracts the "next" URL path+query from a GitHub Link header. +func parseNextLink(header string) string { + if header == "" { + return "" + } + for _, part := range strings.Split(header, ",") { + part = strings.TrimSpace(part) + if !strings.Contains(part, `rel="next"`) { + continue + } + start := strings.Index(part, "<") + end := strings.Index(part, ">") + if start < 0 || end < 0 || end <= start { + continue + } + link := part[start+1 : end] + // Strip the base URL to return just the path+query for our get method + if strings.HasPrefix(link, githubAPIBase) { + return link[len(githubAPIBase):] + } + return link + } + return "" +} + // patPR is the JSON shape from the GitHub REST API for PRs. type patPR struct { Number int `json:"number"` diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index e9542287e5..e7e03d89cd 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -737,6 +737,14 @@ func (s *Service) SearchOrgRepos(ctx context.Context, org, query string, limit i return s.client.SearchOrgRepos(ctx, org, query, limit) } +// ListRepoBranches lists branches for a repository. +func (s *Service) ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + return s.client.ListRepoBranches(ctx, owner, repo) +} + // RecordReviewPRTask records that a task was created for a review PR. func (s *Service) RecordReviewPRTask(ctx context.Context, watchID, repoOwner, repoName string, prNumber int, prURL, taskID string) error { rpt := &ReviewPRTask{ diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index d96738f594..8fa6072bf8 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -50,6 +50,7 @@ var ( ErrExecutionNotFound = errors.New("execution not found") ErrExecutionAlreadyRunning = errors.New("execution already running") ErrRemoteDockerNoRepoURL = errors.New("remote_docker executor requires a repository with provider owner and name set") + ErrNoCloneURL = errors.New("repository has no clone URL: provider owner and name are required") ) // PromptResult contains the result of a prompt operation @@ -288,6 +289,22 @@ type Executor struct { // This prevents race conditions when the backend restarts and multiple resume requests // arrive simultaneously (e.g., from frontend auto-resume). sessionLocks sync.Map // map[string]*sync.Mutex + + // Optional cloner for provider-backed repos without a local path. + repoCloner RepoCloner + repoUpdater RepoUpdater +} + +// RepoCloner clones remote repositories to local disk. +type RepoCloner interface { + EnsureCloned(ctx context.Context, cloneURL, owner, name string) (string, error) + // BuildCloneURL constructs a protocol-aware clone URL for the given provider/owner/name. + BuildCloneURL(provider, owner, name string) (string, error) +} + +// RepoUpdater updates repository records in the database. +type RepoUpdater interface { + UpdateRepositoryLocalPath(ctx context.Context, repositoryID, localPath string) error } // ExecutorConfig holds configuration for the Executor @@ -327,3 +344,9 @@ func (e *Executor) SetOnTaskStateChange(fn TaskStateChangeFunc) { func (e *Executor) SetOnSessionStateChange(fn SessionStateChangeFunc) { e.onSessionStateChange = fn } + +// SetRepoCloner sets the cloner used to clone provider-backed repositories on launch. +func (e *Executor) SetRepoCloner(cloner RepoCloner, updater RepoUpdater) { + e.repoCloner = cloner + e.repoUpdater = updater +} diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index c0f36baf1d..ba9b327897 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -56,6 +56,16 @@ func (e *Executor) resolvePrimaryRepoInfo(ctx context.Context, taskID string) (* zap.Error(err)) return nil, err } + + // Clone provider-backed repos that have no local path yet + if repo.LocalPath == "" && repo.ProviderOwner != "" && repo.ProviderName != "" { + if localPath, cloneErr := e.ensureRepoCloned(ctx, repo); cloneErr != nil { + return nil, cloneErr + } else if localPath != "" { + repo.LocalPath = localPath + } + } + info.Repository = repo info.RepositoryPath = repo.LocalPath info.WorktreeBranchPrefix = repo.WorktreeBranchPrefix @@ -66,6 +76,54 @@ func (e *Executor) resolvePrimaryRepoInfo(ctx context.Context, taskID string) (* return info, nil } +// ensureRepoCloned clones a provider-backed repository to local disk and updates its local path in the database. +// Returns the local path on success, or empty string if no cloner is configured. +func (e *Executor) ensureRepoCloned(ctx context.Context, repo *models.Repository) (string, error) { + if e.repoCloner == nil { + e.logger.Warn("repository has no local path and no cloner configured", + zap.String("repository_id", repo.ID), + zap.String("provider", repo.Provider), + zap.String("owner", repo.ProviderOwner), + zap.String("name", repo.ProviderName)) + return "", nil + } + + cloneURL, urlErr := e.repoCloner.BuildCloneURL(repo.Provider, repo.ProviderOwner, repo.ProviderName) + if urlErr != nil || cloneURL == "" { + // Fall back to HTTPS URL if BuildCloneURL fails + cloneURL = repositoryCloneURL(repo) + if cloneURL == "" { + return "", ErrNoCloneURL + } + } + + e.logger.Info("cloning provider-backed repository for local execution", + zap.String("repository_id", repo.ID), + zap.String("repo", repo.ProviderOwner+"/"+repo.ProviderName)) + + localPath, err := e.repoCloner.EnsureCloned(ctx, cloneURL, repo.ProviderOwner, repo.ProviderName) + if err != nil { + e.logger.Error("failed to clone repository", + zap.String("repository_id", repo.ID), + zap.String("repo", repo.ProviderOwner+"/"+repo.ProviderName), + zap.Error(err)) + return "", err + } + + // Persist the local path so future launches skip cloning + if e.repoUpdater != nil && localPath != "" { + if updateErr := e.repoUpdater.UpdateRepositoryLocalPath(ctx, repo.ID, localPath); updateErr != nil { + e.logger.Warn("failed to update repository local path after clone", + zap.String("repository_id", repo.ID), + zap.String("local_path", localPath), + zap.Error(updateErr)) + // Non-fatal: the clone succeeded, we can use the path + } + } + + return localPath, nil +} + // buildExecutorRunning constructs an ExecutorRunning record from the launch/resume response, // carrying forward resume token and metadata from the previous running record if available. func buildExecutorRunning(session *models.TaskSession, taskID string, resp *LaunchAgentResponse, execCfg executorConfig, existingRunning *models.ExecutorRunning) *models.ExecutorRunning { diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 2137f7457a..de9784c4e3 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -305,6 +305,13 @@ func (s *Service) SetMessageCreator(mc MessageCreator) { s.messageCreator = mc } +// SetRepoCloner sets the repository cloner and updater on the executor, enabling automatic +// cloning of provider-backed repositories (e.g. from a GitHub URL) when they are launched +// for local/worktree execution and have no local path yet. +func (s *Service) SetRepoCloner(cloner executor.RepoCloner, updater executor.RepoUpdater) { + s.executor.SetRepoCloner(cloner, updater) +} + // SetTurnService sets the turn service for tracking conversation turns. // // A "turn" represents a single conversation round-trip: user prompt → agent response. diff --git a/apps/backend/internal/repoclone/clone.go b/apps/backend/internal/repoclone/clone.go index b140b95896..2aee5bb4ae 100644 --- a/apps/backend/internal/repoclone/clone.go +++ b/apps/backend/internal/repoclone/clone.go @@ -60,6 +60,12 @@ func (c *Cloner) ExpandedBasePath() (string, error) { return path, nil } +// BuildCloneURL constructs a protocol-aware clone URL for the given provider/owner/name. +// This ensures the clone URL matches the user's configured git protocol (SSH vs HTTPS). +func (c *Cloner) BuildCloneURL(provider, owner, name string) (string, error) { + return CloneURL(provider, owner, name, c.protocol) +} + // RepoPath returns the full local path for a repository. func (c *Cloner) RepoPath(owner, name string) (string, error) { basePath, err := c.ExpandedBasePath() diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 2edf583f96..5481b5a52a 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -250,6 +250,7 @@ type TaskRepositoryInput struct { LocalPath string Name string DefaultBranch string + GitHubURL string } type CreateTaskRequest struct { diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index ce5dcf5e57..418b608182 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -108,6 +108,7 @@ func convertToServiceRepos(repos []dto.TaskRepositoryInput) []service.TaskReposi LocalPath: r.LocalPath, Name: r.Name, DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, } } return result diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index a7ce19972a..9272b6908d 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -289,6 +289,7 @@ type httpTaskRepositoryInput struct { LocalPath string `json:"local_path"` Name string `json:"name"` DefaultBranch string `json:"default_branch"` + GitHubURL string `json:"github_url"` } type httpCreateTaskRequest struct { @@ -371,8 +372,8 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryInput) ([]dto.TaskRepositoryInput, bool) { var repos []dto.TaskRepositoryInput for _, r := range inputs { - if r.RepositoryID == "" && r.LocalPath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id or local_path is required"}) + if r.RepositoryID == "" && r.LocalPath == "" && r.GitHubURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id, local_path, or github_url is required"}) return nil, false } repos = append(repos, dto.TaskRepositoryInput{ @@ -381,6 +382,7 @@ func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryIn LocalPath: r.LocalPath, Name: r.Name, DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, }) } return repos, true diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index 6bf55bfa79..a716784418 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -112,8 +112,8 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M // Convert repositories var repos []dto.TaskRepositoryInput for _, r := range req.Repositories { - if r.RepositoryID == "" && r.LocalPath == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id or local_path is required", nil) + if r.RepositoryID == "" && r.LocalPath == "" && strings.TrimSpace(r.GitHubURL) == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id, local_path, or github_url is required", nil) } repos = append(repos, dto.TaskRepositoryInput{ RepositoryID: r.RepositoryID, @@ -121,6 +121,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M LocalPath: r.LocalPath, Name: r.Name, DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, }) } @@ -226,6 +227,7 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M LocalPath: r.LocalPath, Name: r.Name, DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, }) } } diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 3e8e07f7e4..132daa9ec4 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -16,6 +16,7 @@ type TaskRepositoryInput struct { LocalPath string `json:"local_path,omitempty"` Name string `json:"name,omitempty"` DefaultBranch string `json:"default_branch,omitempty"` + GitHubURL string `json:"github_url,omitempty"` } // CreateTaskRequest contains the data for creating a new task diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 99cd28477d..53a30aab7f 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "net/url" "path/filepath" "strings" "time" @@ -134,7 +135,38 @@ func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceI func (s *Service) resolveRepoInput(ctx context.Context, workspaceID string, repoInput TaskRepositoryInput, repoByPath map[string]*models.Repository) (repositoryID, baseBranch string, err error) { repositoryID = repoInput.RepositoryID baseBranch = repoInput.BaseBranch - if repositoryID != "" || repoInput.LocalPath == "" { + if repositoryID != "" { + return repositoryID, baseBranch, nil + } + + // Handle GitHub URL: parse owner/name and use FindOrCreateRepository + if repoInput.GitHubURL != "" { + owner, name, parseErr := parseGitHubRepoURL(repoInput.GitHubURL) + if parseErr != nil { + return "", "", parseErr + } + defaultBranch := repoInput.DefaultBranch + if defaultBranch == "" { + defaultBranch = repoInput.BaseBranch + } + repo, createErr := s.FindOrCreateRepository(ctx, &FindOrCreateRepositoryRequest{ + WorkspaceID: workspaceID, + Provider: "github", + ProviderOwner: owner, + ProviderName: name, + DefaultBranch: defaultBranch, + }) + if createErr != nil { + return "", "", createErr + } + repositoryID = repo.ID + if baseBranch == "" { + baseBranch = repo.DefaultBranch + } + return repositoryID, baseBranch, nil + } + + if repoInput.LocalPath == "" { return repositoryID, baseBranch, nil } repo := repoByPath[repoInput.LocalPath] @@ -169,6 +201,40 @@ func (s *Service) resolveRepoInput(ctx context.Context, workspaceID string, repo return repositoryID, baseBranch, nil } +// parseGitHubRepoURL parses a GitHub repository URL into owner and name. +// Supports: https://github.com/owner/repo, github.com/owner/repo, +// https://github.com/owner/repo.git, with optional trailing slashes. +func parseGitHubRepoURL(rawURL string) (owner, name string, err error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "", "", fmt.Errorf("empty GitHub URL") + } + + // Add scheme if missing so url.Parse works correctly + if !strings.Contains(rawURL, "://") { + rawURL = "https://" + rawURL + } + + parsed, parseErr := url.Parse(rawURL) + if parseErr != nil { + return "", "", fmt.Errorf("invalid GitHub URL: %w", parseErr) + } + + if parsed.Host != "github.com" && parsed.Host != "www.github.com" { + return "", "", fmt.Errorf("not a GitHub URL: %s", parsed.Host) + } + + // Path should be /owner/name (possibly with .git suffix and trailing slash) + path := strings.Trim(parsed.Path, "/") + path = strings.TrimSuffix(path, ".git") + parts := strings.Split(path, "/") + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid GitHub repository URL: expected github.com/owner/repo") + } + + return parts[0], parts[1], nil +} + // replaceTaskRepositories deletes all existing task-repository associations and recreates them. func (s *Service) replaceTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error { if err := s.taskRepos.DeleteTaskRepositoriesByTask(ctx, taskID); err != nil { diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 68d22ca2ac..ee166e6053 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -9,6 +9,7 @@ import { CommandRegistryProvider } from "@/lib/commands/command-registry"; import { CommandPanel } from "@/components/command-panel"; import { GlobalCommands } from "@/components/global-commands"; import { DiffWorkerPoolProvider } from "@/components/diff-worker-pool-provider"; +import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge"; export const metadata: Metadata = { title: "KanDev - AI Kanban", @@ -73,6 +74,7 @@ export default function RootLayout({ + diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index da297745e4..428354448a 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -34,6 +34,7 @@ interface ComboboxProps { className?: string; triggerClassName?: string; showSearch?: boolean; + testId?: string; } export const Combobox = memo(function Combobox({ @@ -48,6 +49,7 @@ export const Combobox = memo(function Combobox({ className, triggerClassName, showSearch = true, + testId, }: ComboboxProps) { const [open, setOpen] = useState(false); // Track the highlighted item. Defaults to the selected value so the current @@ -71,6 +73,7 @@ export const Combobox = memo(function Combobox({ aria-expanded={open} className={cn("w-full justify-between", !disabled && "cursor-pointer", triggerClassName)} disabled={disabled} + data-testid={testId} >
{selectedOption?.renderLabel ? ( diff --git a/apps/web/components/session-failure-toast-bridge.tsx b/apps/web/components/session-failure-toast-bridge.tsx new file mode 100644 index 0000000000..8bbf186b11 --- /dev/null +++ b/apps/web/components/session-failure-toast-bridge.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useSessionFailureToast } from "@/hooks/use-session-failure-toast"; + +/** Mounts the session failure toast hook inside the ToastProvider tree. */ +export function SessionFailureToastBridge() { + useSessionFailureToast(); + return null; +} diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index b675d5159b..d89d65e247 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -12,6 +12,7 @@ import { import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; +import { fetchRepoBranches } from "@/lib/api/domains/github-api"; import type { DialogFormState, StoreSelections, @@ -48,9 +49,9 @@ export function useRepositoryAutoSelectEffect( workspaceId: string | null, repositories: Repository[], ) { - const { repositoryId, selectedLocalRepo, setRepositoryId } = fs; + const { repositoryId, selectedLocalRepo, useGitHubUrl, setRepositoryId } = fs; useEffect(() => { - if (!open || !workspaceId || repositoryId || selectedLocalRepo) return; + if (!open || !workspaceId || repositoryId || selectedLocalRepo || useGitHubUrl) return; const lastUsedRepoId = getLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, null); if (lastUsedRepoId && repositories.some((r: Repository) => r.id === lastUsedRepoId)) { void Promise.resolve().then(() => setRepositoryId(lastUsedRepoId)); @@ -58,7 +59,15 @@ export function useRepositoryAutoSelectEffect( } if (repositories.length === 1) void Promise.resolve().then(() => setRepositoryId(repositories[0].id)); - }, [open, repositories, repositoryId, selectedLocalRepo, workspaceId, setRepositoryId]); + }, [ + open, + repositories, + repositoryId, + selectedLocalRepo, + useGitHubUrl, + workspaceId, + setRepositoryId, + ]); } export function useDiscoverReposEffect( @@ -204,7 +213,7 @@ export function useDefaultSelectionsEffect( } export function useBranchAutoSelectEffect(fs: DialogFormState, branches: Branch[]) { - const { repositoryId, branch, localBranches, setBranch } = fs; + const { repositoryId, branch, localBranches, githubBranches, useGitHubUrl, setBranch } = fs; useEffect(() => { if (!repositoryId || branch) return; autoSelectBranch(branches, setBranch); @@ -213,6 +222,84 @@ export function useBranchAutoSelectEffect(fs: DialogFormState, branches: Branch[ if (repositoryId || localBranches.length === 0 || branch) return; autoSelectBranch(localBranches, setBranch); }, [branch, localBranches, repositoryId, setBranch]); + useEffect(() => { + if (!useGitHubUrl || githubBranches.length === 0 || branch) return; + // GitHub URL branches are referenced by name only (no remote prefix). + // selectPreferredBranch expects origin-prefixed remotes, so pick directly. + const preferred = + githubBranches.find((b) => b.name === "main") ?? + githubBranches.find((b) => b.name === "master") ?? + githubBranches[0]; + if (preferred) setBranch(preferred.name); + }, [branch, githubBranches, useGitHubUrl, setBranch]); +} + +/** Parse a GitHub URL to extract owner and repo name. Returns null if invalid. */ +function parseGitHubUrl(url: string): { owner: string; repo: string } | null { + const trimmed = url.trim(); + if (!trimmed) return null; + // Match github.com/owner/repo with optional scheme, .git suffix, trailing slash + const match = trimmed.match( + /(?:https?:\/\/)?(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/, + ); + if (!match) return null; + return { owner: match[1], repo: match[2] }; +} + +export function useGitHubUrlBranchesEffect(fs: DialogFormState, open: boolean) { + const { + useGitHubUrl, + githubUrl, + setGitHubBranches, + setGitHubBranchesLoading, + setGitHubUrlError, + } = fs; + useEffect(() => { + if (!open || !useGitHubUrl) { + setGitHubBranchesLoading(false); + return; + } + const trimmed = githubUrl.trim(); + if (!trimmed) { + setGitHubBranches([]); + setGitHubBranchesLoading(false); + setGitHubUrlError(null); + return; + } + const parsed = parseGitHubUrl(githubUrl); + if (!parsed) { + setGitHubBranches([]); + setGitHubUrlError("Invalid GitHub URL — expected github.com/owner/repo"); + return; + } + let cancelled = false; + setGitHubUrlError(null); + setGitHubBranchesLoading(true); + fetchRepoBranches(parsed.owner, parsed.repo) + .then((res) => { + if (cancelled) return; + setGitHubBranches(res.branches.map((b) => ({ name: b.name, type: "remote" as const }))); + setGitHubUrlError(null); + }) + .catch(() => { + if (cancelled) return; + setGitHubUrlError(`Repository not found or not accessible`); + setGitHubBranches([]); + }) + .finally(() => { + if (!cancelled) setGitHubBranchesLoading(false); + }); + return () => { + cancelled = true; + }; + }, [ + open, + useGitHubUrl, + githubUrl, + setGitHubBranches, + setGitHubBranchesLoading, + setGitHubUrlError, + ]); } export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreateEffectsArgs) { @@ -224,4 +311,5 @@ export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreate useBranchAutoSelectEffect(fs, branches); useLocalBranchesEffect(fs, open, workspaceId, toast); useDefaultSelectionsEffect(fs, open, { agentProfiles, executors, workspaceDefaults }); + useGitHubUrlBranchesEffect(fs, open); } diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index f73c3321fe..53393b23a7 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -15,7 +15,6 @@ type SelectorOption = { type CreateEditSelectorsProps = { isTaskStarted: boolean; hasRepositorySelection: boolean; - repositoryId: string; branchOptions: SelectorOption[]; branch: string; onBranchChange: (value: string) => void; @@ -65,7 +64,6 @@ type CreateEditSelectorsProps = { export const CreateEditSelectors = memo(function CreateEditSelectors({ isTaskStarted, hasRepositorySelection, - repositoryId, branchOptions, branch, onBranchChange, @@ -89,16 +87,15 @@ export const CreateEditSelectors = memo(function CreateEditSelectors({ const branchPlaceholder = (() => { if (!hasRepositorySelection) return "Select repository first"; - if (repositoryId) { - return branchesLoading ? "Loading branches..." : "Select branch"; - } - if (localBranchesLoading) return "Loading branches..."; + if (branchesLoading || localBranchesLoading) return "Loading branches..."; return branchOptions.length > 0 ? "Select branch" : "No branches found"; })(); const branchDisabled = !hasRepositorySelection || - (repositoryId ? branchesLoading : localBranchesLoading || branchOptions.length === 0); + branchesLoading || + localBranchesLoading || + branchOptions.length === 0; const agentPlaceholder = (() => { if (agentProfilesLoading) return "Loading agents..."; diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 48cc83ea32..bf3a10639c 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -117,6 +117,7 @@ export function validateCreateInputs(inputs: { effectiveWorkflowId: string | null; repositoryId: string; selectedLocalRepo: LocalRepository | null; + githubUrl?: string; agentProfileId: string; }): boolean { return Boolean( @@ -124,6 +125,6 @@ export function validateCreateInputs(inputs: { inputs.workspaceId && inputs.effectiveWorkflowId && inputs.agentProfileId && - (inputs.repositoryId || inputs.selectedLocalRepo), + (inputs.repositoryId || inputs.selectedLocalRepo || inputs.githubUrl?.trim()), ); } diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index e5d24fbce5..841485e5c1 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -184,6 +184,7 @@ export const ExecutorProfileSelector = memo(function ExecutorProfileSelector({ dropdownLabel="Executor Profile" className={disabled ? undefined : CURSOR_POINTER_CLASS} triggerClassName={triggerClassName} + testId="executor-profile-selector" /> ); }); diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 0b06370aa2..db68be4794 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -55,6 +55,10 @@ type FormResetters = { setSelectedLocalRepo: (v: LocalRepository | null) => void; setLocalBranches: (v: Branch[]) => void; setDiscoverReposLoaded: (v: boolean) => void; + setUseGitHubUrl: (v: boolean) => void; + setGitHubUrl: (v: string) => void; + setGitHubBranches: (v: Branch[]) => void; + setGitHubUrlError: (v: string | null) => void; }; function useFormResetEffects( @@ -96,11 +100,35 @@ function useFormResetEffects( resetters.setSelectedLocalRepo(null); resetters.setLocalBranches([]); resetters.setDiscoverReposLoaded(false); + resetters.setUseGitHubUrl(false); + resetters.setGitHubUrl(""); + resetters.setGitHubBranches([]); + resetters.setGitHubUrlError(null); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, workspaceId]); } +function useGitHubUrlState() { + const [useGitHubUrl, setUseGitHubUrl] = useState(false); + const [githubUrl, setGitHubUrl] = useState(""); + const [githubBranches, setGitHubBranches] = useState([]); + const [githubBranchesLoading, setGitHubBranchesLoading] = useState(false); + const [githubUrlError, setGitHubUrlError] = useState(null); + return { + useGitHubUrl, + setUseGitHubUrl, + githubUrl, + setGitHubUrl, + githubBranches, + setGitHubBranches, + githubBranchesLoading, + setGitHubBranchesLoading, + githubUrlError, + setGitHubUrlError, + }; +} + export function useDialogFormState( open: boolean, workspaceId: string | null, @@ -127,6 +155,7 @@ export function useDialogFormState( const [fetchedSteps, setFetchedSteps] = useState(null); const [isCreatingSession, setIsCreatingSession] = useState(false); const [isCreatingTask, setIsCreatingTask] = useState(false); + const ghUrl = useGitHubUrlState(); useFormResetEffects(open, workspaceId, workflowId, initialValues, { setTaskName, setHasTitle, @@ -143,6 +172,10 @@ export function useDialogFormState( setSelectedLocalRepo, setLocalBranches, setDiscoverReposLoaded, + setUseGitHubUrl: ghUrl.setUseGitHubUrl, + setGitHubUrl: ghUrl.setGitHubUrl, + setGitHubBranches: ghUrl.setGitHubBranches, + setGitHubUrlError: ghUrl.setGitHubUrlError, }); return { taskName, @@ -184,6 +217,7 @@ export function useDialogFormState( setIsCreatingSession, isCreatingTask, setIsCreatingTask, + ...ghUrl, }; } @@ -216,6 +250,9 @@ export function useDialogHandlers(fs: DialogFormState, repositories: Repository[ fs.setSelectedLocalRepo(null); fs.setLocalBranches([]); fs.setBranch(""); + fs.setUseGitHubUrl(false); + fs.setGitHubUrl(""); + fs.setGitHubBranches([]); return; } handleSelectLocalRepository(value); @@ -258,6 +295,32 @@ export function useDialogHandlers(fs: DialogFormState, repositories: Repository[ [fs], ); + const handleToggleGitHubUrl = useCallback(() => { + const next = !fs.useGitHubUrl; + fs.setUseGitHubUrl(next); + if (next) { + fs.setRepositoryId(""); + fs.setDiscoveredRepoPath(""); + fs.setSelectedLocalRepo(null); + fs.setLocalBranches([]); + } else { + fs.setGitHubUrl(""); + fs.setGitHubBranches([]); + fs.setGitHubUrlError(null); + } + fs.setBranch(""); + }, [fs]); + + const handleGitHubUrlChange = useCallback( + (value: string) => { + fs.setGitHubUrl(value); + fs.setBranch(""); + fs.setGitHubBranches([]); + fs.setGitHubUrlError(null); + }, + [fs], + ); + return { handleRepositoryChange, handleAgentProfileChange, @@ -265,6 +328,8 @@ export function useDialogHandlers(fs: DialogFormState, repositories: Repository[ handleTaskNameChange, handleBranchChange, handleWorkflowChange, + handleToggleGitHubUrl, + handleGitHubUrlChange, }; } @@ -295,8 +360,15 @@ export function useDialogComputed({ const workspaceDefaults = workspaceId ? workspaces.find((ws: Workspace) => ws.id === workspaceId) : null; - const hasRepositorySelection = Boolean(fs.repositoryId || fs.selectedLocalRepo); - const branchOptions = useBranchOptions(fs.repositoryId ? branches : fs.localBranches); + const hasRepositorySelection = Boolean( + fs.repositoryId || fs.selectedLocalRepo || (fs.useGitHubUrl && fs.githubUrl.trim()), + ); + const effectiveBranches = (() => { + if (fs.useGitHubUrl) return fs.githubBranches; + if (fs.repositoryId) return branches; + return fs.localBranches; + })(); + const branchOptions = useBranchOptions(effectiveBranches); const agentProfileOptions = useAgentProfileOptions(agentProfiles); const allExecutorProfiles = useMemo(() => { return executors.flatMap((executor) => diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index c446953a0c..bf6cc4744e 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -29,6 +29,8 @@ export type SubmitHandlersDeps = { effectiveDefaultStepId: string | null; repositoryId: string; selectedLocalRepo: LocalRepository | null; + useGitHubUrl: boolean; + githubUrl: string; branch: string; agentProfileId: string; executorId: string; @@ -75,6 +77,8 @@ export function useTaskSubmitHandlers({ effectiveDefaultStepId, repositoryId, selectedLocalRepo, + useGitHubUrl, + githubUrl, branch, agentProfileId, executorId, @@ -127,6 +131,15 @@ export function useTaskSubmitHandlers({ ]); const getRepositoriesPayload = useCallback(() => { + if (useGitHubUrl && githubUrl) { + return [ + { + repository_id: "", + base_branch: branch || undefined, + github_url: githubUrl, + }, + ]; + } if (repositoryId) { return [{ repository_id: repositoryId, base_branch: branch || undefined }]; } @@ -141,7 +154,7 @@ export function useTaskSubmitHandlers({ ]; } return []; - }, [repositoryId, branch, selectedLocalRepo]); + }, [useGitHubUrl, repositoryId, branch, githubUrl, selectedLocalRepo]); const handleSessionSubmit = useCallback(async () => { const description = descriptionInputRef.current?.getValue() ?? ""; @@ -421,6 +434,7 @@ export function useTaskSubmitHandlers({ effectiveWorkflowId, repositoryId, selectedLocalRepo, + githubUrl, agentProfileId, }) ) @@ -449,6 +463,7 @@ export function useTaskSubmitHandlers({ effectiveWorkflowId, repositoryId, selectedLocalRepo, + githubUrl, agentProfileId, getRepositoriesPayload, performCreateWithAgent, @@ -468,6 +483,7 @@ export function useTaskSubmitHandlers({ effectiveWorkflowId, repositoryId, selectedLocalRepo, + githubUrl, agentProfileId, }) ) @@ -495,6 +511,7 @@ export function useTaskSubmitHandlers({ effectiveWorkflowId, repositoryId, selectedLocalRepo, + githubUrl, agentProfileId, isPassthroughProfile, getRepositoriesPayload, @@ -516,6 +533,7 @@ export function useTaskSubmitHandlers({ effectiveWorkflowId, repositoryId, selectedLocalRepo, + githubUrl, agentProfileId, }) ) @@ -556,6 +574,7 @@ export function useTaskSubmitHandlers({ effectiveWorkflowId, repositoryId, selectedLocalRepo, + githubUrl, agentProfileId, effectiveDefaultStepId, executorId, diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index b3641b57ad..caebb8f564 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -120,4 +120,14 @@ export type DialogFormState = { setIsCreatingSession: (v: boolean) => void; isCreatingTask: boolean; setIsCreatingTask: (v: boolean) => void; + useGitHubUrl: boolean; + setUseGitHubUrl: (v: boolean) => void; + githubUrl: string; + setGitHubUrl: (v: string) => void; + githubBranches: Branch[]; + setGitHubBranches: (v: Branch[]) => void; + githubBranchesLoading: boolean; + setGitHubBranchesLoading: (v: boolean) => void; + githubUrlError: string | null; + setGitHubUrlError: (v: string | null) => void; }; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 0e8cc213c5..a542504dea 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -96,48 +96,118 @@ type DialogHeaderContentProps = { headerRepositoryOptions: ReturnType["headerRepositoryOptions"]; onRepositoryChange: (v: string) => void; onTaskNameChange: (v: string) => void; + useGitHubUrl: boolean; + githubUrl: string; + githubUrlError: string | null; + onToggleGitHubUrl: () => void; + onGitHubUrlChange: (v: string) => void; }; -function DialogHeaderContent({ - isCreateMode, - isEditMode, +function RepoSourceInput({ + useGitHubUrl, + githubUrl, + githubUrlError, + onGitHubUrlChange, isTaskStarted, - sessionRepoName, - initialTitle, - taskName, + headerRepositoryOptions, repositoryId, discoveredRepoPath, + onRepositoryChange, workspaceId, repositoriesLoading, discoverReposLoading, - headerRepositoryOptions, - onRepositoryChange, - onTaskNameChange, -}: DialogHeaderContentProps) { +}: Pick< + DialogHeaderContentProps, + | "useGitHubUrl" + | "githubUrl" + | "githubUrlError" + | "onGitHubUrlChange" + | "isTaskStarted" + | "headerRepositoryOptions" + | "repositoryId" + | "discoveredRepoPath" + | "onRepositoryChange" + | "workspaceId" + | "repositoriesLoading" + | "discoverReposLoading" +>): React.ReactNode { + if (useGitHubUrl) { + return ( +
+ onGitHubUrlChange(e.target.value)} + placeholder="github.com/owner/repo" + data-testid="github-url-input" + aria-label="GitHub repository URL" + aria-invalid={!!githubUrlError} + aria-describedby={githubUrlError ? "github-url-error" : undefined} + size={Math.max((githubUrl || "").length + 1, "github.com/owner/repo".length)} + className={`bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 h-7 rounded-md px-2 hover:bg-muted focus:bg-muted transition-colors placeholder:text-muted-foreground ${githubUrlError ? "text-destructive" : ""}`} + disabled={isTaskStarted} + autoFocus + /> + {githubUrlError && ( + + )} +
+ ); + } + return ( + + ); +} + +function DialogHeaderContent(props: DialogHeaderContentProps) { + const { isCreateMode, isEditMode, isTaskStarted, sessionRepoName, initialTitle } = props; + const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl } = props; + if (isCreateMode || isEditMode) { return ( -
- - / - +
+
+ + / + +
+ {!isTaskStarted && ( +
+ +
+ )}
); @@ -243,7 +313,6 @@ function DialogFormBody({
@@ -422,7 +498,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) { initialDescription={initialValues?.description ?? ""} hasDescription={fs.hasDescription} branchOptions={computed.branchOptions} - branchesLoading={branchesLoading} + branchesLoading={branchesLoading || (fs.useGitHubUrl && fs.githubBranchesLoading)} agentProfileOptions={computed.agentProfileOptions} executorProfileOptions={computed.executorProfileOptions} agentProfiles={agentProfiles} diff --git a/apps/web/components/toast-provider.tsx b/apps/web/components/toast-provider.tsx index 9c9200c934..ef9b052245 100644 --- a/apps/web/components/toast-provider.tsx +++ b/apps/web/components/toast-provider.tsx @@ -125,39 +125,49 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { return ( {children} -
- {toasts.map((t) => { - const variant = t.variant ?? "default"; - const styles = variantStyles[variant]; - const Icon = styles.IconComponent; - return ( -
-
- -
-
- {t.title &&
{t.title}
} - {t.description && ( -
- {t.description} -
- )} -
-
- ); - })} -
+
); } +function ToastList({ toasts }: { toasts: Toast[] }) { + return ( +
+ {toasts.map((t) => { + const variant = t.variant ?? "default"; + const styles = variantStyles[variant]; + const Icon = styles.IconComponent; + return ( +
+
+ +
+
+ {t.title &&
{t.title}
} + {t.description && ( +
{t.description}
+ )} +
+
+ ); + })} +
+ ); +} + export function useToast() { const context = useContext(ToastContext); if (!context) { diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 942bafde85..2306a817d0 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -209,15 +209,35 @@ export class ApiClient { workspaceId: string, localPath: string, defaultBranch = "main", + opts?: { + name?: string; + provider?: string; + provider_owner?: string; + provider_name?: string; + }, ): Promise<{ id: string }> { return this.request("POST", `/api/v1/workspaces/${workspaceId}/repositories`, { - name: "E2E Repo", + name: opts?.name ?? "E2E Repo", source_type: "local", local_path: localPath, default_branch: defaultBranch, + ...(opts?.provider ? { provider: opts.provider } : {}), + ...(opts?.provider_owner ? { provider_owner: opts.provider_owner } : {}), + ...(opts?.provider_name ? { provider_name: opts.provider_name } : {}), }); } + async listExecutors(): Promise<{ + executors: Array<{ + id: string; + name: string; + type: string; + profiles?: Array<{ id: string; name: string }>; + }>; + }> { + return this.request("GET", "/api/v1/executors"); + } + async saveUserSettings(settings: { enable_preview_on_click?: boolean; workspace_id?: string; @@ -344,6 +364,18 @@ export class ApiClient { }); } + async mockGitHubAddBranches( + owner: string, + repo: string, + branches: Array<{ name: string }>, + ): Promise { + await this.request("POST", "/api/v1/github/mock/branches", { + owner, + repo, + branches, + }); + } + async mockGitHubAssociateTaskPR(data: { task_id: string; owner: string; diff --git a/apps/web/e2e/tests/create-task-github-url.spec.ts b/apps/web/e2e/tests/create-task-github-url.spec.ts new file mode 100644 index 0000000000..1226206ee7 --- /dev/null +++ b/apps/web/e2e/tests/create-task-github-url.spec.ts @@ -0,0 +1,359 @@ +import { test, expect } from "../fixtures/test-base"; +import { KanbanPage } from "../pages/kanban-page"; +import { SessionPage } from "../pages/session-page"; + +test.describe("Task creation from GitHub URL", () => { + // Allow one retry for transient backend port-allocation issues on cold start. + test.describe.configure({ retries: 1 }); + + test("can create a task using a GitHub URL with workspace interaction", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + // Pre-seed a repository with GitHub provider info pointing to the real local git repo. + // This lets FindOrCreateRepository find the repo (with its local_path) when the + // GitHub URL is submitted, avoiding an actual clone. + const repoDir = `${backend.tmpDir}/repos/e2e-repo`; + await apiClient.createRepository(seedData.workspaceId, repoDir, "main", { + name: "test-owner/test-repo", + provider: "github", + provider_owner: "test-owner", + provider_name: "test-repo", + }); + + // Seed mock GitHub branches for the repo we'll reference + await apiClient.mockGitHubAddBranches("test-owner", "test-repo", [ + { name: "main" }, + { name: "develop" }, + { name: "feature/test" }, + ]); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + // Toggle to GitHub URL mode + const toggleBtn = testPage.getByTestId("toggle-github-url"); + await expect(toggleBtn).toBeVisible(); + await toggleBtn.click(); + + // Enter a GitHub URL + const urlInput = testPage.getByTestId("github-url-input"); + await expect(urlInput).toBeVisible(); + await urlInput.fill("https://github.com/test-owner/test-repo"); + + // Fill in title and description + await testPage.getByTestId("task-title-input").fill("GitHub URL Task"); + await testPage.getByTestId("task-description-input").fill("/e2e:simple-message"); + + // Wait for the start button to become enabled (branches + agent profile resolved) + const startBtn = testPage.getByTestId("submit-start-agent"); + await expect(startBtn).toBeEnabled({ timeout: 15_000 }); + + // Click "Start task" + await startBtn.click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + // The new task card appears on the kanban board + const card = kanban.taskCardByTitle("GitHub URL Task"); + await expect(card).toBeVisible({ timeout: 10_000 }); + + // Click the card to navigate to the session + await card.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Wait for the agent to complete + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + + // Session transitions to idle + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + + // ── Workspace interaction: terminal + changes ── + await expect(session.terminal).toBeVisible({ timeout: 15_000 }); + + // Create a file via the terminal + const testFileName = "ghtest"; + await session.typeInTerminal(`touch ${testFileName}`); + + // Switch to the Changes tab — the new file proves the terminal command worked + await session.clickTab("Changes"); + await expect(session.changes).toBeVisible({ timeout: 10_000 }); + await expect(session.changes.getByText(testFileName)).toBeVisible({ timeout: 15_000 }); + }); + + test("can create a GitHub URL task with worktree executor", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + // Pre-seed the GitHub-backed repository + const repoDir = `${backend.tmpDir}/repos/e2e-repo`; + await apiClient.createRepository(seedData.workspaceId, repoDir, "main", { + name: "test-owner/test-repo", + provider: "github", + provider_owner: "test-owner", + provider_name: "test-repo", + }); + + await apiClient.mockGitHubAddBranches("test-owner", "test-repo", [ + { name: "main" }, + { name: "develop" }, + ]); + + // Look up the worktree executor profile + const { executors } = await apiClient.listExecutors(); + const worktreeExec = executors.find((e) => e.type === "worktree"); + const worktreeProfile = worktreeExec?.profiles?.[0]; + if (!worktreeProfile) { + test.skip(true, "No worktree executor profile available"); + return; + } + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + // Toggle to GitHub URL mode + await testPage.getByTestId("toggle-github-url").click(); + await testPage.getByTestId("github-url-input").fill("https://github.com/test-owner/test-repo"); + + // Fill in title and description + await testPage.getByTestId("task-title-input").fill("Worktree GitHub Task"); + await testPage.getByTestId("task-description-input").fill("/e2e:simple-message"); + + // Wait for selectors to be ready + const startBtn = testPage.getByTestId("submit-start-agent"); + await expect(startBtn).toBeEnabled({ timeout: 15_000 }); + + // Select the worktree executor profile + const executorSelector = testPage.getByTestId("executor-profile-selector"); + await executorSelector.click(); + await testPage.getByRole("option", { name: /Worktree/i }).click(); + + // Submit + await startBtn.click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + const card = kanban.taskCardByTitle("Worktree GitHub Task"); + await expect(card).toBeVisible({ timeout: 10_000 }); + + await card.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + }); + + test("shows error for invalid GitHub URL format", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + // Toggle to GitHub URL mode + await testPage.getByTestId("toggle-github-url").click(); + const urlInput = testPage.getByTestId("github-url-input"); + const errorEl = testPage.getByTestId("github-url-error"); + + // Type an invalid URL — error should appear + await urlInput.fill("not-a-github-url"); + await expect(errorEl).toBeVisible({ timeout: 5_000 }); + await expect(errorEl).toContainText("Invalid GitHub URL"); + + // Clear the input — error should disappear + await urlInput.fill(""); + await expect(errorEl).not.toBeVisible({ timeout: 5_000 }); + + // Type another invalid URL (missing repo) + await urlInput.fill("https://github.com/owner-only"); + await expect(errorEl).toBeVisible({ timeout: 5_000 }); + }); + + test("shows error for nonexistent repository", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + // Toggle to GitHub URL mode and enter a repo that isn't seeded in mock data + await testPage.getByTestId("toggle-github-url").click(); + await testPage + .getByTestId("github-url-input") + .fill("https://github.com/no-such-owner/no-such-repo"); + + // The error should appear after the branch fetch fails + const errorEl = testPage.getByTestId("github-url-error"); + await expect(errorEl).toBeVisible({ timeout: 10_000 }); + await expect(errorEl).toContainText("not found or not accessible"); + }); + + test("clears error when valid repository URL is entered", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + // Pre-seed a valid repo + const repoDir = `${backend.tmpDir}/repos/e2e-repo`; + await apiClient.createRepository(seedData.workspaceId, repoDir, "main", { + name: "test-owner/test-repo", + provider: "github", + provider_owner: "test-owner", + provider_name: "test-repo", + }); + await apiClient.mockGitHubAddBranches("test-owner", "test-repo", [{ name: "main" }]); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + await testPage.getByTestId("toggle-github-url").click(); + const urlInput = testPage.getByTestId("github-url-input"); + const errorEl = testPage.getByTestId("github-url-error"); + + // Start with an invalid URL — error appears + await urlInput.fill("bad-url"); + await expect(errorEl).toBeVisible({ timeout: 5_000 }); + + // Replace with a valid, seeded URL — error clears, branches load + await urlInput.fill("https://github.com/test-owner/test-repo"); + await expect(errorEl).not.toBeVisible({ timeout: 10_000 }); + + // Branches should load and start button should become enabled + const startBtn = testPage.getByTestId("submit-start-agent"); + await testPage.getByTestId("task-title-input").fill("Validation Test"); + await testPage.getByTestId("task-description-input").fill("test"); + await expect(startBtn).toBeEnabled({ timeout: 15_000 }); + }); + + test("uses correct repository when creating tasks from different GitHub URLs", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + // Seed two distinct GitHub-backed repositories + const repoDirA = `${backend.tmpDir}/repos/e2e-repo`; + await apiClient.createRepository(seedData.workspaceId, repoDirA, "main", { + name: "owner-a/repo-a", + provider: "github", + provider_owner: "owner-a", + provider_name: "repo-a", + }); + await apiClient.mockGitHubAddBranches("owner-a", "repo-a", [{ name: "main" }]); + + const repoDirB = `${backend.tmpDir}/repos/e2e-repo-b`; + const { execSync } = await import("child_process"); + const fs = await import("fs"); + fs.mkdirSync(repoDirB, { recursive: true }); + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "E2E Test", + GIT_AUTHOR_EMAIL: "e2e@test.local", + GIT_COMMITTER_NAME: "E2E Test", + GIT_COMMITTER_EMAIL: "e2e@test.local", + }; + execSync("git init -b main", { cwd: repoDirB, env: gitEnv }); + execSync('git commit --allow-empty -m "init"', { cwd: repoDirB, env: gitEnv }); + await apiClient.createRepository(seedData.workspaceId, repoDirB, "main", { + name: "owner-b/repo-b", + provider: "github", + provider_owner: "owner-b", + provider_name: "repo-b", + }); + await apiClient.mockGitHubAddBranches("owner-b", "repo-b", [{ name: "main" }]); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + // ── First task from repo-a ── + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + await testPage.getByTestId("toggle-github-url").click(); + await testPage.getByTestId("github-url-input").fill("https://github.com/owner-a/repo-a"); + await testPage.getByTestId("task-title-input").fill("Task A"); + await testPage.getByTestId("task-description-input").fill("/e2e:simple-message"); + const startBtn = testPage.getByTestId("submit-start-agent"); + await expect(startBtn).toBeEnabled({ timeout: 15_000 }); + await startBtn.click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + await expect(kanban.taskCardByTitle("Task A")).toBeVisible({ timeout: 10_000 }); + + // ── Second task from repo-b (different URL) ── + await kanban.createTaskButton.first().click(); + await expect(dialog).toBeVisible(); + await testPage.getByTestId("toggle-github-url").click(); + await testPage.getByTestId("github-url-input").fill("https://github.com/owner-b/repo-b"); + await testPage.getByTestId("task-title-input").fill("Task B"); + await testPage.getByTestId("task-description-input").fill("/e2e:simple-message"); + await expect(startBtn).toBeEnabled({ timeout: 15_000 }); + await startBtn.click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + // Both cards visible — second task created successfully on its own repo + await expect(kanban.taskCardByTitle("Task B")).toBeVisible({ timeout: 10_000 }); + + // Navigate to Task B session and verify agent completes + await kanban.taskCardByTitle("Task B").click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + }); + + test("can toggle between GitHub URL and repository selector", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + // Initially the toggle says "or paste a GitHub URL" + const toggleBtn = testPage.getByTestId("toggle-github-url"); + await expect(toggleBtn).toHaveText("or paste a GitHub URL"); + + // Toggle to GitHub URL mode + await toggleBtn.click(); + await expect(testPage.getByTestId("github-url-input")).toBeVisible(); + await expect(toggleBtn).toHaveText("or select a repository"); + + // Toggle back to repository selector + await toggleBtn.click(); + await expect(testPage.getByTestId("github-url-input")).not.toBeVisible(); + await expect(toggleBtn).toHaveText("or paste a GitHub URL"); + }); +}); diff --git a/apps/web/e2e/tests/diff-update.spec.ts b/apps/web/e2e/tests/diff-update.spec.ts index 84684d5ea1..4fe952e5c4 100644 --- a/apps/web/e2e/tests/diff-update.spec.ts +++ b/apps/web/e2e/tests/diff-update.spec.ts @@ -61,7 +61,7 @@ async function openDiffUpdateFileDiff(testPage: Page) { } test.describe("Diff update on file change", () => { - test.describe.configure({ retries: 1, timeout: 120_000 }); + test.describe.configure({ retries: 2, timeout: 120_000 }); test("shows initial diff with FIRST_MODIFICATION", async ({ testPage, apiClient, seedData }) => { await seedDiffUpdateTask(testPage, apiClient, seedData); @@ -105,18 +105,23 @@ test.describe("Diff update on file change", () => { await openChangesTab(testPage); await openDiffUpdateFileDiff(testPage); - // Wait for the diffs container to be visible again - await expect(diffsContainer).toBeVisible({ timeout: 15_000 }); + // Re-query the diffs container since the DOM may have changed after tab switch + const updatedDiffsContainer = testPage.locator("diffs-container"); + await expect(updatedDiffsContainer).toBeVisible({ timeout: 15_000 }); // The diff should now show SECOND_MODIFICATION instead of FIRST_MODIFICATION. - // Use a longer timeout because the workspace polling loop needs to detect the - // file change and push updated git status — can be slow on CI runners. - await expect(diffsContainer.getByText("SECOND_MODIFICATION", { exact: true })).toBeVisible({ - timeout: 30_000, - }); + // Allow extra time for git polling to detect the change and re-render the diff. + await expect( + updatedDiffsContainer.getByText("SECOND_MODIFICATION", { exact: true }), + ).toBeVisible({ timeout: 30_000 }); + + // Verify FIRST_MODIFICATION is no longer shown (replaced, not merged) + await expect( + updatedDiffsContainer.getByText("FIRST_MODIFICATION", { exact: true }), + ).toHaveCount(0); // Also verify the additional change on line 3 - await expect(diffsContainer.getByText("ALSO_CHANGED", { exact: true })).toBeVisible({ + await expect(updatedDiffsContainer.getByText("ALSO_CHANGED", { exact: true })).toBeVisible({ timeout: 15_000, }); }); diff --git a/apps/web/hooks/use-session-failure-toast.ts b/apps/web/hooks/use-session-failure-toast.ts new file mode 100644 index 0000000000..6542952175 --- /dev/null +++ b/apps/web/hooks/use-session-failure-toast.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect } from "react"; +import { useAppStore } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; + +/** + * Watches for session failure notifications in the store and shows an error toast. + * Mount once in a global layout component inside ToastProvider. + */ +export function useSessionFailureToast() { + const notification = useAppStore((s) => s.sessionFailureNotification); + const clearNotification = useAppStore((s) => s.setSessionFailureNotification); + const { toast } = useToast(); + + useEffect(() => { + if (!notification) return; + toast({ + title: "Task failed to start", + description: notification.message, + variant: "error", + }); + clearNotification(null); + }, [notification, toast, clearNotification]); +} diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts index 5e12b5c4ca..9e046f96a2 100644 --- a/apps/web/lib/api/domains/github-api.ts +++ b/apps/web/lib/api/domains/github-api.ts @@ -141,6 +141,14 @@ export async function searchOrgRepos(org: string, query?: string, options?: ApiR ); } +// Remote repo branches +export async function fetchRepoBranches(owner: string, repo: string, options?: ApiRequestOptions) { + return fetchJson<{ branches: { name: string }[] }>( + `/api/v1/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, + options, + ); +} + // Stats export async function fetchGitHubStats( params?: { workspace_id?: string; start_date?: string; end_date?: string }, diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index 59d915262f..8fbce8541a 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -36,6 +36,7 @@ export async function createTask( local_path?: string; name?: string; default_branch?: string; + github_url?: string; }>; state?: Task["state"]; start_agent?: boolean; diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index d289b737b1..3895640efb 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -57,6 +57,12 @@ export type SystemHealthState = { loading: boolean; }; +export type SessionFailureNotification = { + sessionId: string; + taskId: string; + message: string; +}; + export type UISliceState = { previewPanel: PreviewPanelState; rightPanel: RightPanelState; @@ -67,6 +73,7 @@ export type UISliceState = { chatInput: ChatInputState; documentPanel: DocumentPanelState; systemHealth: SystemHealthState; + sessionFailureNotification: SessionFailureNotification | null; }; export type UISliceActions = { @@ -88,6 +95,7 @@ export type UISliceActions = { setSystemHealth: (response: SystemHealthResponse) => void; setSystemHealthLoading: (loading: boolean) => void; invalidateSystemHealth: () => void; + setSessionFailureNotification: (n: SessionFailureNotification | null) => void; }; export type UISlice = UISliceState & UISliceActions; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 522dab5699..710dcc5f47 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -19,6 +19,7 @@ export const defaultUIState: UISliceState = { chatInput: { planModeBySessionId: {} }, documentPanel: { activeDocumentBySessionId: {} }, systemHealth: { issues: [], healthy: true, loaded: false, loading: false }, + sessionFailureNotification: null, }; export const createUISlice: StateCreator = ( @@ -109,4 +110,8 @@ export const createUISlice: StateCreator { draft.systemHealth.loaded = false; }), + setSessionFailureNotification: (n) => + set((draft) => { + draft.sessionFailureNotification = n; + }), }); diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index b086c01c41..cdc11d868a 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -191,6 +191,7 @@ export type AppState = { chatInput: (typeof defaultUIState)["chatInput"]; documentPanel: (typeof defaultUIState)["documentPanel"]; systemHealth: (typeof defaultUIState)["systemHealth"]; + sessionFailureNotification: (typeof defaultUIState)["sessionFailureNotification"]; // GitHub actions setGitHubStatus: (status: GitHubStatus | null) => void; @@ -292,6 +293,9 @@ export type AppState = { setSystemHealth: (response: SystemHealthResponse) => void; setSystemHealthLoading: (loading: boolean) => void; invalidateSystemHealth: () => void; + setSessionFailureNotification: ( + n: import("./slices/ui/types").SessionFailureNotification | null, + ) => void; setMessages: ( sessionId: string, messages: Message[], diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index b1308566c1..25a92cc308 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -199,6 +199,7 @@ export type TaskSessionStateChangedPayload = { agent_profile_snapshot?: Record; metadata?: Record; is_passthrough?: boolean; + error_message?: string; // Workflow-related fields (sent during workflow transitions) review_status?: string; workflow_step_id?: string; diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index f6b75b8323..2755ab01b2 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -161,6 +161,16 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle upsertTaskSessionList(store, taskId, sessionId, payload, sessionUpdate); extractContextWindow(store, sessionId, payload); + + if (newState === "FAILED") { + store.getState().setSessionFailureNotification({ + sessionId, + taskId, + message: payload.error_message + ? String(payload.error_message) + : "Session failed unexpectedly", + }); + } }, "session.agentctl_starting": (message) => { const payload = message.payload;