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;